diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e543b0fa..fabcbf1b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -86,6 +86,7 @@ dependencies {
implementation(projects.feature.exercise)
implementation(projects.feature.timer)
implementation(projects.feature.matching)
+ implementation(projects.feature.feed)
implementation(projects.feature.chatting)
implementation(libs.androidx.appcompat)
diff --git a/app/src/main/java/com/project200/undabang/main/MainActivity.kt b/app/src/main/java/com/project200/undabang/main/MainActivity.kt
index 26307f6b..392c858b 100644
--- a/app/src/main/java/com/project200/undabang/main/MainActivity.kt
+++ b/app/src/main/java/com/project200/undabang/main/MainActivity.kt
@@ -146,6 +146,8 @@ class MainActivity : AppCompatActivity(), BottomNavigationController {
com.project200.undabang.feature.exercise.R.id.exerciseShareEditFragment,
com.project200.undabang.feature.matching.R.id.matchingGuideFragment,
com.project200.undabang.feature.chatting.R.id.chattingRoomFragment,
+ com.project200.undabang.feature.feed.R.id.feedFormFragment,
+ com.project200.undabang.feature.feed.R.id.feedDetailFragment,
// ... 필요한 다른 프래그먼트 ID들 추가 ... //
)
diff --git a/app/src/main/res/drawable/ic_nav_feed.xml b/app/src/main/res/drawable/ic_nav_feed.xml
new file mode 100644
index 00000000..caef363e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_feed.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml
index 0dd8bf96..56eba78e 100644
--- a/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/app/src/main/res/menu/bottom_nav_menu.xml
@@ -8,14 +8,18 @@
android:id="@id/matching_nav_graph"
android:icon="@drawable/ic_nav_matching"
android:title="@string/matching" />
+
-
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b8452b34..781d437a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -10,6 +10,7 @@
기록
매칭
+ 피드
채팅
타이머
MY
diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt
index 2188a5c2..77294b29 100644
--- a/data/src/main/java/com/project200/data/api/ApiService.kt
+++ b/data/src/main/java/com/project200/data/api/ApiService.kt
@@ -1,11 +1,18 @@
package com.project200.data.api
import com.project200.data.dto.BaseResponse
+import com.project200.data.dto.CommentDTO
+import com.project200.data.dto.CreateCommentRequestDTO
+import com.project200.data.dto.CreateCommentResponseDTO
+import com.project200.data.dto.CreateFeedRequestDTO
import com.project200.data.dto.CustomTimerIdDTO
import com.project200.data.dto.DeletePreferredExerciseDTO
import com.project200.data.dto.EditExercisePlaceDTO
import com.project200.data.dto.ExerciseIdDto
import com.project200.data.dto.ExpectedScoreInfoDTO
+import com.project200.data.dto.FeedCreateResultDTO
+import com.project200.data.dto.FeedDTO
+import com.project200.data.dto.FeedPictureUploadDTO
import com.project200.data.dto.GetBlockedMemberDTO
import com.project200.data.dto.GetChattingMessagesDTO
import com.project200.data.dto.GetChattingRoomsDTO
@@ -15,6 +22,7 @@ import com.project200.data.dto.GetExerciseCountByRangeDTO
import com.project200.data.dto.GetExercisePlaceDTO
import com.project200.data.dto.GetExerciseRecordData
import com.project200.data.dto.GetExerciseRecordListDto
+import com.project200.data.dto.GetFeedsDTO
import com.project200.data.dto.GetIsNicknameDuplicated
import com.project200.data.dto.GetIsRegisteredData
import com.project200.data.dto.GetMatchingMembersDto
@@ -27,6 +35,7 @@ import com.project200.data.dto.GetProfileDTO
import com.project200.data.dto.GetProfileImageResponseDto
import com.project200.data.dto.GetScoreDTO
import com.project200.data.dto.GetSimpleTimersDTO
+import com.project200.data.dto.LikeRequestDTO
import com.project200.data.dto.NotificationStateDTO
import com.project200.data.dto.PatchCustomTimerTitleRequest
import com.project200.data.dto.PatchExerciseRequestDto
@@ -46,6 +55,7 @@ import com.project200.data.dto.PostSignUpRequest
import com.project200.data.dto.PutProfileRequest
import com.project200.data.dto.SimpleTimerIdDTO
import com.project200.data.dto.SimpleTimerRequest
+import com.project200.data.dto.UpdateFeedRequestDTO
import com.project200.data.utils.AccessTokenApi
import com.project200.data.utils.AccessTokenWithFcmApi
import com.project200.data.utils.IdTokenApi
@@ -474,4 +484,94 @@ interface ApiService {
@Header("X-Fcm-Token") fcmToken: String,
@Body notiRequest: List,
): BaseResponse
+
+ /** 피드 */
+ @GET("api/v1/feeds")
+ @AccessTokenApi
+ suspend fun getFeeds(
+ @Query("prevFeedId") prevFeedId: Long?,
+ @Query("size") size: Int?,
+ ): BaseResponse
+
+ @GET("api/v1/feeds/{feedId}")
+ @AccessTokenApi
+ suspend fun getFeedDetail(
+ @Path("feedId") feedId: Long,
+ ): BaseResponse
+
+ @POST("api/v1/feeds")
+ @AccessTokenApi
+ suspend fun postFeed(
+ @Body createFeedRequest: CreateFeedRequestDTO,
+ ): BaseResponse
+
+ @DELETE("api/v1/feeds/{feedId}")
+ @AccessTokenApi
+ suspend fun deleteFeed(
+ @Path("feedId") feedId: Long,
+ ): BaseResponse
+
+ @PATCH("api/v1/feeds/{feedId}")
+ @AccessTokenApi
+ suspend fun updateFeed(
+ @Path("feedId") feedId: Long,
+ @Body updateFeedRequest: UpdateFeedRequestDTO,
+ ): BaseResponse
+
+ // 피드 좋아요
+ @POST("api/v1/feeds/{feedId}/like")
+ @AccessTokenApi
+ suspend fun likeFeed(
+ @Path("feedId") feedId: Long,
+ @Body request: LikeRequestDTO,
+ ): BaseResponse
+
+ /** 댓글 */
+ // 댓글 목록 조회
+ @GET("api/v1/feeds/{feedId}/comments")
+ @AccessTokenApi
+ suspend fun getComments(
+ @Path("feedId") feedId: Long,
+ ): BaseResponse>
+
+ // 댓글 작성
+ @POST("api/v1/feeds/{feedId}/comments")
+ @AccessTokenApi
+ suspend fun createComment(
+ @Path("feedId") feedId: Long,
+ @Body request: CreateCommentRequestDTO,
+ ): BaseResponse
+
+ // 댓글 좋아요
+ @POST("api/v1/comments/{commentId}/like")
+ @AccessTokenApi
+ suspend fun likeComment(
+ @Path("commentId") commentId: Long,
+ @Body request: LikeRequestDTO,
+ ): BaseResponse
+
+ // 댓글 삭제
+ @DELETE("api/v1/comments/{commentId}")
+ @AccessTokenApi
+ suspend fun deleteComment(
+ @Path("commentId") commentId: Long,
+ ): BaseResponse
+
+ /** 피드 이미지 */
+ // 피드 이미지 업로드
+ @Multipart
+ @POST("api/v1/feeds/{feedId}/pictures")
+ @AccessTokenApi
+ suspend fun postFeedImages(
+ @Path("feedId") feedId: Long,
+ @Part pictures: List,
+ ): BaseResponse>
+
+ // 피드 이미지 삭제
+ @DELETE("api/v1/feeds/{feedId}/pictures/{pictureId}")
+ @AccessTokenApi
+ suspend fun deleteFeedImage(
+ @Path("feedId") feedId: Long,
+ @Path("pictureId") pictureId: Long,
+ ): BaseResponse
}
diff --git a/data/src/main/java/com/project200/data/di/RepositoryModule.kt b/data/src/main/java/com/project200/data/di/RepositoryModule.kt
index e5d411a8..09365e31 100644
--- a/data/src/main/java/com/project200/data/di/RepositoryModule.kt
+++ b/data/src/main/java/com/project200/data/di/RepositoryModule.kt
@@ -7,6 +7,7 @@ import com.project200.data.impl.ChatSocketRepositoryImpl
import com.project200.data.impl.ChattingRepositoryImpl
import com.project200.data.impl.ExerciseRecordRepositoryImpl
import com.project200.data.impl.FcmRepositoryImpl
+import com.project200.data.impl.FeedRepositoryImpl
import com.project200.data.impl.MatchingRepositoryImpl
import com.project200.data.impl.MemberRepositoryImpl
import com.project200.data.impl.NotificationRepositoryImpl
@@ -20,6 +21,7 @@ import com.project200.domain.repository.ChatSocketRepository
import com.project200.domain.repository.ChattingRepository
import com.project200.domain.repository.ExerciseRecordRepository
import com.project200.domain.repository.FcmRepository
+import com.project200.domain.repository.FeedRepository
import com.project200.domain.repository.MatchingRepository
import com.project200.domain.repository.MemberRepository
import com.project200.domain.repository.NotificationRepository
@@ -86,4 +88,8 @@ abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindNotificationRepository(notificationRepositoryImpl: NotificationRepositoryImpl): NotificationRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindFeedRepository(feedRepositoryImpl: FeedRepositoryImpl): FeedRepository
}
diff --git a/data/src/main/java/com/project200/data/dto/CommentDTO.kt b/data/src/main/java/com/project200/data/dto/CommentDTO.kt
new file mode 100644
index 00000000..363e5b50
--- /dev/null
+++ b/data/src/main/java/com/project200/data/dto/CommentDTO.kt
@@ -0,0 +1,56 @@
+package com.project200.data.dto
+
+import com.squareup.moshi.JsonClass
+import java.time.LocalDateTime
+
+@JsonClass(generateAdapter = true)
+data class GetCommentsDTO(
+ val comments: List,
+)
+
+@JsonClass(generateAdapter = true)
+data class CommentDTO(
+ val commentId: Long,
+ val memberId: String,
+ val memberNickname: String,
+ val memberProfileImageUrl: String?,
+ val memberThumbnailUrl: String?,
+ val content: String,
+ val likesCount: Int,
+ val taggedMember: TaggedMemberDTO,
+ val commentIsLiked: Boolean = false,
+ val createdAt: LocalDateTime,
+ val children: List,
+)
+
+@JsonClass(generateAdapter = true)
+data class TaggedMemberDTO(
+ val memberId: String?,
+ val memberNickname: String?,
+)
+
+@JsonClass(generateAdapter = true)
+data class ReplyDTO(
+ val commentId: Long,
+ val memberId: String,
+ val memberNickname: String,
+ val memberProfileImageUrl: String?,
+ val memberThumbnailUrl: String?,
+ val content: String,
+ val likesCount: Int,
+ val commentIsLiked: Boolean = false,
+ val createdAt: LocalDateTime,
+ val taggedMember: TaggedMemberDTO? = null,
+)
+
+@JsonClass(generateAdapter = true)
+data class CreateCommentRequestDTO(
+ val content: String,
+ val parentCommentId: Long?,
+ val taggedMemberId: String? = null,
+)
+
+@JsonClass(generateAdapter = true)
+data class CreateCommentResponseDTO(
+ val commentId: Long,
+)
diff --git a/data/src/main/java/com/project200/data/dto/FeedDTO.kt b/data/src/main/java/com/project200/data/dto/FeedDTO.kt
new file mode 100644
index 00000000..38d7645b
--- /dev/null
+++ b/data/src/main/java/com/project200/data/dto/FeedDTO.kt
@@ -0,0 +1,63 @@
+package com.project200.data.dto
+
+import com.squareup.moshi.JsonClass
+import java.time.LocalDateTime
+
+@JsonClass(generateAdapter = true)
+data class GetFeedsDTO(
+ val hasNext: Boolean,
+ val feeds: List,
+)
+
+@JsonClass(generateAdapter = true)
+data class FeedDTO(
+ val feedId: Long,
+ val feedContent: String,
+ val feedLikesCount: Int,
+ val feedCommentsCount: Int,
+ val feedTypeId: Long?,
+ val feedTypeName: String?,
+ val feedTypeDesc: String?,
+ val feedCreatedAt: LocalDateTime,
+ val feedIsLiked: Boolean,
+ val feedHasCommented: Boolean,
+ val memberId: String,
+ val nickname: String,
+ val profileUrl: String?,
+ val thumbnailUrl: String?,
+ val feedPictures: List,
+)
+
+@JsonClass(generateAdapter = true)
+data class FeedPictureDTO(
+ val feedPictureId: Long,
+ val feedPictureUrl: String,
+)
+
+@JsonClass(generateAdapter = true)
+data class CreateFeedRequestDTO(
+ val feedContent: String,
+ val feedTypeId: Long?,
+)
+
+@JsonClass(generateAdapter = true)
+data class FeedCreateResultDTO(
+ val feedId: Long,
+)
+
+@JsonClass(generateAdapter = true)
+data class UpdateFeedRequestDTO(
+ val feedContent: String,
+ val feedTypeId: Long?,
+)
+
+@JsonClass(generateAdapter = true)
+data class FeedPictureUploadDTO(
+ val pictureId: Long,
+ val pictureUrl: String,
+)
+
+@JsonClass(generateAdapter = true)
+data class LikeRequestDTO(
+ val liked: Boolean,
+)
diff --git a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt
index c485bebd..a0469356 100644
--- a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt
+++ b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt
@@ -69,6 +69,15 @@ class ChatSocketRepositoryImpl
private var heartbeatJob: Job? = null
private var retryJob: Job? = null
+ // [MEASURE] 측정용 변수
+ private val connectionAttemptCount = AtomicInteger(0)
+ private val connectionSuccessCount = AtomicInteger(0)
+ private val connectionFailCount = AtomicInteger(0)
+ private val reconnectCount = AtomicInteger(0)
+ private val reconnectSuccessCount = AtomicInteger(0)
+ private var lastDisconnectTime: Long = 0L
+ private var totalDowntimeMs: Long = 0L
+
// 네트워크 복구 감지
init {
CoroutineScope(Dispatchers.IO).launch {
@@ -94,6 +103,25 @@ class ChatSocketRepositoryImpl
* 채팅방 소켓 연결 해제
*/
override fun disconnect() {
+ // [MEASURE] 세션 통계 출력
+ Timber.w("[MEASURE] === 세션 통계 ===")
+ Timber.w("[MEASURE] 연결 시도: ${connectionAttemptCount.get()}")
+ Timber.w("[MEASURE] 연결 성공: ${connectionSuccessCount.get()}")
+ Timber.w("[MEASURE] 연결 실패: ${connectionFailCount.get()}")
+ Timber.w("[MEASURE] 연결 끊김(재연결 시도): ${reconnectCount.get()}")
+ Timber.w("[MEASURE] 재연결 성공: ${reconnectSuccessCount.get()}")
+ Timber.w("[MEASURE] 총 다운타임: ${totalDowntimeMs}ms")
+ if (connectionAttemptCount.get() > 0) {
+ Timber.w("[MEASURE] 연결 성공률: ${connectionSuccessCount.get() * 100 / connectionAttemptCount.get()}%")
+ }
+ if (reconnectCount.get() > 0) {
+ Timber.w("[MEASURE] 재연결 성공률: ${reconnectSuccessCount.get() * 100 / reconnectCount.get()}%")
+ Timber.w(
+ "[MEASURE] 평균 복구 시간: ${if (reconnectSuccessCount.get() > 0) totalDowntimeMs / reconnectSuccessCount.get() else 0}ms",
+ )
+ }
+ Timber.w("[MEASURE] ================")
+
isUserInChatRoom = false
stopHeartbeat()
retryJob?.cancel()
@@ -122,6 +150,8 @@ class ChatSocketRepositoryImpl
Timber.d("WebSocket already connected.")
return@withLock
}
+ connectionAttemptCount.incrementAndGet()
+ Timber.w("[MEASURE] 연결 시도 #${connectionAttemptCount.get()}")
try {
// 티켓 발급
val response = chatApi.getChatTicket(chatRoomId)
@@ -134,11 +164,14 @@ class ChatSocketRepositoryImpl
if (BuildConfig.DEBUG) {
"$BASE_URL_DEBUG$ticket"
} else {
- "$BASE_URL_RELEASE$ticket"
+ // "$BASE_URL_RELEASE$ticket"
+ "$BASE_URL_DEBUG$ticket"
}
val request = Request.Builder().url(wsUrl).build()
webSocket = okHttpClient.newWebSocket(request, socketListener)
} catch (e: Exception) {
+ connectionFailCount.incrementAndGet()
+ Timber.w("[MEASURE] 연결 실패 #${connectionFailCount.get()}: ${e.message}")
handleConnectionFailure(e)
}
}
@@ -154,6 +187,18 @@ class ChatSocketRepositoryImpl
webSocket: WebSocket,
response: Response,
) {
+ connectionSuccessCount.incrementAndGet()
+ if (lastDisconnectTime > 0L) {
+ val downtimeMs = System.currentTimeMillis() - lastDisconnectTime
+ totalDowntimeMs += downtimeMs
+ reconnectSuccessCount.incrementAndGet()
+ Timber.w(
+ "[MEASURE] 재연결 성공! 복구 소요시간: ${downtimeMs}ms, " +
+ "재연결 성공: ${reconnectSuccessCount.get()}/${reconnectCount.get()}",
+ )
+ lastDisconnectTime = 0L
+ }
+ Timber.w("[MEASURE] 연결 성공 (${connectionSuccessCount.get()}/${connectionAttemptCount.get()})")
retryCount.set(0)
startApplicationHeartbeat()
}
@@ -233,6 +278,9 @@ class ChatSocketRepositoryImpl
}
private fun cleanupAndRetry(t: Throwable) {
+ reconnectCount.incrementAndGet()
+ lastDisconnectTime = System.currentTimeMillis()
+ Timber.w("[MEASURE] 연결 끊김 #${reconnectCount.get()}: ${t.message}")
stopHeartbeat()
webSocket = null
handleConnectionFailure(t)
diff --git a/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt
new file mode 100644
index 00000000..69ff49ab
--- /dev/null
+++ b/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt
@@ -0,0 +1,201 @@
+package com.project200.data.impl
+
+import android.content.Context
+import androidx.core.net.toUri
+import com.project200.common.di.IoDispatcher
+import com.project200.data.api.ApiService
+import com.project200.data.dto.CommentDTO
+import com.project200.data.dto.CreateCommentRequestDTO
+import com.project200.data.dto.CreateCommentResponseDTO
+import com.project200.data.dto.FeedCreateResultDTO
+import com.project200.data.dto.FeedDTO
+import com.project200.data.dto.FeedPictureUploadDTO
+import com.project200.data.dto.GetFeedsDTO
+import com.project200.data.dto.LikeRequestDTO
+import com.project200.data.mapper.toDTO
+import com.project200.data.mapper.toModel
+import com.project200.data.mapper.toMultipartBodyPart
+import com.project200.data.utils.apiCallBuilder
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Comment
+import com.project200.domain.model.CreateCommentResult
+import com.project200.domain.model.CreateFeedModel
+import com.project200.domain.model.Feed
+import com.project200.domain.model.FeedCreateResult
+import com.project200.domain.model.FeedListResult
+import com.project200.domain.model.FeedPicture
+import com.project200.domain.model.UpdateFeedModel
+import com.project200.domain.repository.FeedRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+class FeedRepositoryImpl
+ @Inject
+ constructor(
+ private val apiService: ApiService,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ @ApplicationContext private val context: Context,
+ ) : FeedRepository {
+ override suspend fun getFeeds(
+ prevFeedId: Long?,
+ size: Int?,
+ ): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.getFeeds(prevFeedId, size) },
+ mapper = { dto: GetFeedsDTO? ->
+ dto?.toModel() ?: FeedListResult(hasNext = false, feeds = emptyList())
+ },
+ )
+ }
+
+ override suspend fun getFeedDetail(feedId: Long): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.getFeedDetail(feedId) },
+ mapper = { dto: FeedDTO? ->
+ dto?.toModel() ?: throw NoSuchElementException("피드 상세 데이터가 없습니다.")
+ },
+ )
+ }
+
+ override suspend fun createFeed(createFeedModel: CreateFeedModel): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.postFeed(createFeedModel.toDTO()) },
+ mapper = { dto: FeedCreateResultDTO? ->
+ dto?.toModel() ?: throw NoSuchElementException("피드 생성 결과 데이터가 없습니다.")
+ },
+ )
+ }
+
+ override suspend fun deleteFeed(feedId: Long): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.deleteFeed(feedId) },
+ mapper = { Unit },
+ )
+ }
+
+ override suspend fun getComments(feedId: Long): BaseResult> {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.getComments(feedId) },
+ mapper = { dtoList: List? ->
+ dtoList?.map { it.toModel() } ?: emptyList()
+ },
+ )
+ }
+
+ override suspend fun createComment(
+ feedId: Long,
+ content: String,
+ parentCommentId: Long?,
+ taggedMemberId: String?,
+ ): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = {
+ apiService.createComment(
+ feedId = feedId,
+ request =
+ CreateCommentRequestDTO(
+ content = content,
+ parentCommentId = parentCommentId,
+ taggedMemberId = taggedMemberId,
+ ),
+ )
+ },
+ mapper = { dto: CreateCommentResponseDTO? ->
+ dto?.toModel() ?: throw NoSuchElementException("댓글 생성 결과 데이터가 없습니다.")
+ },
+ )
+ }
+
+ override suspend fun likeComment(
+ commentId: Long,
+ liked: Boolean,
+ ): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.likeComment(commentId, LikeRequestDTO(liked)) },
+ mapper = { Unit },
+ )
+ }
+
+ override suspend fun deleteComment(commentId: Long): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.deleteComment(commentId) },
+ mapper = { Unit },
+ )
+ }
+
+ override suspend fun likeFeed(
+ feedId: Long,
+ liked: Boolean,
+ ): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.likeFeed(feedId, LikeRequestDTO(liked)) },
+ mapper = { Unit },
+ )
+ }
+
+ override suspend fun updateFeed(updateFeedModel: UpdateFeedModel): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.updateFeed(updateFeedModel.feedId, updateFeedModel.toDTO()) },
+ mapper = { Unit },
+ )
+ }
+
+ override suspend fun uploadFeedImages(
+ feedId: Long,
+ imageUris: List,
+ ): BaseResult> {
+ val uris =
+ imageUris.mapNotNull {
+ try {
+ it.toUri()
+ } catch (e: CancellationException) {
+ Timber.w(e, "CancellationException: $it")
+ throw e
+ } catch (e: Exception) {
+ Timber.w(e, "Invalid URI string: $it")
+ null
+ }
+ }
+
+ val imageParts =
+ uris.mapNotNull { uri ->
+ uri.toMultipartBodyPart(context, "pictures")
+ }
+
+ if (imageParts.isEmpty() && uris.isNotEmpty()) {
+ return BaseResult.Error("CONVERSION_FAILED", "이미지 파일 변환에 실패했습니다.")
+ }
+
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.postFeedImages(feedId, imageParts) },
+ mapper = { dtoList: List? ->
+ dtoList?.map { it.toModel() } ?: emptyList()
+ },
+ )
+ }
+
+ override suspend fun deleteFeedImage(
+ feedId: Long,
+ imageId: Long,
+ ): BaseResult {
+ return apiCallBuilder(
+ ioDispatcher = ioDispatcher,
+ apiCall = { apiService.deleteFeedImage(feedId, imageId) },
+ mapper = { Unit },
+ )
+ }
+ }
diff --git a/data/src/main/java/com/project200/data/mapper/CommentMapper.kt b/data/src/main/java/com/project200/data/mapper/CommentMapper.kt
new file mode 100644
index 00000000..48641fa9
--- /dev/null
+++ b/data/src/main/java/com/project200/data/mapper/CommentMapper.kt
@@ -0,0 +1,54 @@
+package com.project200.data.mapper
+
+import com.project200.data.dto.CommentDTO
+import com.project200.data.dto.CreateCommentResponseDTO
+import com.project200.data.dto.ReplyDTO
+import com.project200.data.dto.TaggedMemberDTO
+import com.project200.domain.model.Comment
+import com.project200.domain.model.CreateCommentResult
+import com.project200.domain.model.Reply
+import com.project200.domain.model.TaggedMember
+
+fun CommentDTO.toModel(): Comment {
+ return Comment(
+ commentId = commentId,
+ memberId = memberId,
+ memberNickname = memberNickname,
+ memberProfileImageUrl = memberProfileImageUrl,
+ memberThumbnailUrl = memberThumbnailUrl,
+ content = content,
+ likesCount = likesCount,
+ isLiked = commentIsLiked,
+ createdAt = createdAt,
+ children = children.map { it.toModel() },
+ )
+}
+
+fun ReplyDTO.toModel(): Reply {
+ return Reply(
+ commentId = commentId,
+ memberId = memberId,
+ memberNickname = memberNickname,
+ memberProfileImageUrl = memberProfileImageUrl,
+ memberThumbnailUrl = memberThumbnailUrl,
+ content = content,
+ likesCount = likesCount,
+ isLiked = commentIsLiked,
+ createdAt = createdAt,
+ taggedMember = taggedMember?.toModel(),
+ )
+}
+
+fun TaggedMemberDTO.toModel(): TaggedMember? {
+ if (memberId == null || memberNickname == null) return null
+ return TaggedMember(
+ memberId = memberId,
+ memberNickname = memberNickname,
+ )
+}
+
+fun CreateCommentResponseDTO.toModel(): CreateCommentResult {
+ return CreateCommentResult(
+ commentId = commentId,
+ )
+}
diff --git a/data/src/main/java/com/project200/data/mapper/FeedMapper.kt b/data/src/main/java/com/project200/data/mapper/FeedMapper.kt
new file mode 100644
index 00000000..fd00d51d
--- /dev/null
+++ b/data/src/main/java/com/project200/data/mapper/FeedMapper.kt
@@ -0,0 +1,76 @@
+package com.project200.data.mapper
+
+import com.project200.data.dto.CreateFeedRequestDTO
+import com.project200.data.dto.FeedCreateResultDTO
+import com.project200.data.dto.FeedDTO
+import com.project200.data.dto.FeedPictureDTO
+import com.project200.data.dto.FeedPictureUploadDTO
+import com.project200.data.dto.GetFeedsDTO
+import com.project200.data.dto.UpdateFeedRequestDTO
+import com.project200.domain.model.CreateFeedModel
+import com.project200.domain.model.Feed
+import com.project200.domain.model.FeedCreateResult
+import com.project200.domain.model.FeedListResult
+import com.project200.domain.model.FeedPicture
+import com.project200.domain.model.UpdateFeedModel
+
+fun GetFeedsDTO.toModel(): FeedListResult {
+ return FeedListResult(
+ hasNext = hasNext,
+ feeds = feeds.map { it.toModel() },
+ )
+}
+
+fun FeedDTO.toModel(): Feed {
+ return Feed(
+ feedId = feedId,
+ feedContent = feedContent,
+ feedLikesCount = feedLikesCount,
+ feedCommentsCount = feedCommentsCount,
+ feedTypeId = feedTypeId,
+ feedTypeName = feedTypeName,
+ feedTypeDesc = feedTypeDesc,
+ feedCreatedAt = feedCreatedAt,
+ feedIsLiked = feedIsLiked,
+ feedHasCommented = feedHasCommented,
+ memberId = memberId,
+ nickname = nickname,
+ profileUrl = profileUrl,
+ thumbnailUrl = thumbnailUrl,
+ feedPictures = feedPictures.map { it.toModel() },
+ )
+}
+
+fun FeedPictureDTO.toModel(): FeedPicture {
+ return FeedPicture(
+ feedPictureId = feedPictureId,
+ feedPictureUrl = feedPictureUrl,
+ )
+}
+
+fun CreateFeedModel.toDTO(): CreateFeedRequestDTO {
+ return CreateFeedRequestDTO(
+ feedContent = feedContent,
+ feedTypeId = feedTypeId,
+ )
+}
+
+fun FeedCreateResultDTO.toModel(): FeedCreateResult {
+ return FeedCreateResult(
+ feedId = feedId,
+ )
+}
+
+fun UpdateFeedModel.toDTO(): UpdateFeedRequestDTO {
+ return UpdateFeedRequestDTO(
+ feedContent = feedContent,
+ feedTypeId = feedTypeId,
+ )
+}
+
+fun FeedPictureUploadDTO.toModel(): FeedPicture {
+ return FeedPicture(
+ feedPictureId = pictureId,
+ feedPictureUrl = pictureUrl,
+ )
+}
diff --git a/domain/src/main/java/com/project200/domain/model/FeedModel.kt b/domain/src/main/java/com/project200/domain/model/FeedModel.kt
new file mode 100644
index 00000000..204f766f
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/model/FeedModel.kt
@@ -0,0 +1,81 @@
+package com.project200.domain.model
+
+import java.time.LocalDateTime
+
+data class FeedListResult(
+ val hasNext: Boolean,
+ val feeds: List,
+)
+
+data class Feed(
+ val feedId: Long,
+ val feedContent: String,
+ val feedLikesCount: Int,
+ val feedCommentsCount: Int,
+ val feedTypeId: Long?,
+ val feedTypeName: String?,
+ val feedTypeDesc: String?,
+ val feedCreatedAt: LocalDateTime,
+ val feedIsLiked: Boolean,
+ val feedHasCommented: Boolean,
+ val memberId: String,
+ val nickname: String,
+ val profileUrl: String?,
+ val thumbnailUrl: String?,
+ val feedPictures: List,
+)
+
+data class FeedPicture(
+ val feedPictureId: Long,
+ val feedPictureUrl: String,
+)
+
+data class CreateFeedModel(
+ val feedContent: String,
+ val feedTypeId: Long?,
+)
+
+data class UpdateFeedModel(
+ val feedId: Long,
+ val feedContent: String,
+ val feedTypeId: Long?,
+)
+
+data class FeedCreateResult(
+ val feedId: Long,
+)
+
+data class Comment(
+ val commentId: Long,
+ val memberId: String,
+ val memberNickname: String,
+ val memberProfileImageUrl: String?,
+ val memberThumbnailUrl: String?,
+ val content: String,
+ val likesCount: Int,
+ val isLiked: Boolean,
+ val createdAt: LocalDateTime,
+ val children: List,
+)
+
+data class TaggedMember(
+ val memberId: String?,
+ val memberNickname: String?,
+)
+
+data class Reply(
+ val commentId: Long,
+ val memberId: String,
+ val memberNickname: String,
+ val memberProfileImageUrl: String?,
+ val memberThumbnailUrl: String?,
+ val content: String,
+ val likesCount: Int,
+ val isLiked: Boolean,
+ val createdAt: LocalDateTime,
+ val taggedMember: TaggedMember? = null,
+)
+
+data class CreateCommentResult(
+ val commentId: Long,
+)
diff --git a/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt b/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt
new file mode 100644
index 00000000..6442b551
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt
@@ -0,0 +1,37 @@
+package com.project200.domain.repository
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Comment
+import com.project200.domain.model.CreateCommentResult
+import com.project200.domain.model.CreateFeedModel
+import com.project200.domain.model.Feed
+import com.project200.domain.model.FeedCreateResult
+import com.project200.domain.model.FeedPicture
+import com.project200.domain.model.FeedListResult
+import com.project200.domain.model.UpdateFeedModel
+
+interface FeedRepository {
+ suspend fun getFeeds(prevFeedId: Long?, size: Int? = null): BaseResult
+
+ suspend fun getFeedDetail(feedId: Long): BaseResult
+
+ suspend fun createFeed(createFeedModel: CreateFeedModel): BaseResult
+
+ suspend fun deleteFeed(feedId: Long): BaseResult
+
+ suspend fun updateFeed(updateFeedModel: UpdateFeedModel): BaseResult
+
+ suspend fun uploadFeedImages(feedId: Long, imageUris: List): BaseResult>
+
+ suspend fun deleteFeedImage(feedId: Long, imageId: Long): BaseResult
+
+ suspend fun getComments(feedId: Long): BaseResult>
+
+ suspend fun createComment(feedId: Long, content: String, parentCommentId: Long?, taggedMemberId: String? = null): BaseResult
+
+ suspend fun likeComment(commentId: Long, liked: Boolean): BaseResult
+
+ suspend fun deleteComment(commentId: Long): BaseResult
+
+ suspend fun likeFeed(feedId: Long, liked: Boolean): BaseResult
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt
new file mode 100644
index 00000000..2d8e8ca0
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt
@@ -0,0 +1,19 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.CreateCommentResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class CreateCommentUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(
+ feedId: Long,
+ content: String,
+ parentCommentId: Long? = null,
+ taggedMemberId: String? = null,
+ ): BaseResult {
+ return feedRepository.createComment(feedId, content, parentCommentId, taggedMemberId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt
new file mode 100644
index 00000000..8c83af85
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt
@@ -0,0 +1,15 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.CreateFeedModel
+import com.project200.domain.model.FeedCreateResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class CreateFeedUseCase @Inject constructor(
+ private val feedRepository: FeedRepository
+) {
+ suspend operator fun invoke(createFeedModel: CreateFeedModel): BaseResult {
+ return feedRepository.createFeed(createFeedModel)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt
new file mode 100644
index 00000000..282fbb0f
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt
@@ -0,0 +1,13 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class DeleteCommentUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(commentId: Long): BaseResult {
+ return feedRepository.deleteComment(commentId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt
new file mode 100644
index 00000000..a3d93389
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt
@@ -0,0 +1,13 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class DeleteFeedImageUseCase @Inject constructor(
+ private val feedRepository: FeedRepository
+) {
+ suspend operator fun invoke(feedId: Long, imageId: Long): BaseResult {
+ return feedRepository.deleteFeedImage(feedId, imageId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt
new file mode 100644
index 00000000..4f693a0f
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt
@@ -0,0 +1,13 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class DeleteFeedUseCase @Inject constructor(
+ private val feedRepository: FeedRepository
+) {
+ suspend operator fun invoke(feedId: Long): BaseResult {
+ return feedRepository.deleteFeed(feedId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt
new file mode 100644
index 00000000..abc132d3
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Comment
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class GetCommentsUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(feedId: Long): BaseResult> {
+ return feedRepository.getComments(feedId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt
new file mode 100644
index 00000000..27715359
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Feed
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class GetFeedDetailUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(feedId: Long): BaseResult {
+ return feedRepository.getFeedDetail(feedId)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt
new file mode 100644
index 00000000..1a12e65f
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.FeedListResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class GetFeedsUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(prevFeedId: Long? = null, size: Int? = null): BaseResult {
+ return feedRepository.getFeeds(prevFeedId, size)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt
new file mode 100644
index 00000000..30b54ba2
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt
@@ -0,0 +1,13 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class LikeCommentUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(commentId: Long, liked: Boolean): BaseResult {
+ return feedRepository.likeComment(commentId, liked)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt
new file mode 100644
index 00000000..aef6e25b
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt
@@ -0,0 +1,13 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class LikeFeedUseCase @Inject constructor(
+ private val feedRepository: FeedRepository,
+) {
+ suspend operator fun invoke(feedId: Long, liked: Boolean): BaseResult {
+ return feedRepository.likeFeed(feedId, liked)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt
new file mode 100644
index 00000000..1981095d
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.UpdateFeedModel
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class UpdateFeedUseCase @Inject constructor(
+ private val feedRepository: FeedRepository
+) {
+ suspend operator fun invoke(updateFeedModel: UpdateFeedModel): BaseResult {
+ return feedRepository.updateFeed(updateFeedModel)
+ }
+}
diff --git a/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt
new file mode 100644
index 00000000..8ba6857a
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.FeedPicture
+import com.project200.domain.repository.FeedRepository
+import javax.inject.Inject
+
+class UploadFeedImagesUseCase @Inject constructor(
+ private val feedRepository: FeedRepository
+) {
+ suspend operator fun invoke(feedId: Long, imageUris: List): BaseResult> {
+ return feedRepository.uploadFeedImages(feedId, imageUris)
+ }
+}
diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt
index e69dc43f..0772ab01 100644
--- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt
+++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt
@@ -391,7 +391,7 @@ class ChattingRoomFragment : BindingFragment(R.layo
if (chatRoomStateRepository.activeChatRoomId.value == args.roomId) {
chatRoomStateRepository.setActiveChatRoomId(null)
}
- viewModel.disconnect()
+ viewModel.scheduleDisconnect()
}
companion object {
diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
index d76628c5..d619dafa 100644
--- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
+++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
@@ -14,12 +14,15 @@ import com.project200.domain.usecase.ObserveOpponentStatusUseCase
import com.project200.domain.usecase.ObserveSocketMessagesUseCase
import com.project200.domain.usecase.SendSocketMessageUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@@ -53,6 +56,8 @@ class ChattingRoomViewModel
private var lastChatId: Long? = null // 새 메시지 조회를 위한 마지막 메시지 ID
var hasNextMessages: Boolean = true // 더 로드할 메시지가 있는지 여부
+ private var disconnectJob: Job? = null
+
init {
viewModelScope.launch {
observeSocketMessagesUseCase().collect { chat ->
@@ -80,16 +85,35 @@ class ChattingRoomViewModel
loadInitialMessages(chatRoomId)
}
- // 소켓 연결 및 공백 채우기
+ // 소켓 연결 및 공백 채우기 (유예 시간 내 복귀 시 재연결 생략)
fun connectAndSync() {
if (chatRoomId == DEFAULT_ID) return
- // 소켓 연결 시도
- connectChatRoomUseCase(chatRoomId)
- // 소켓이 끊겨있던 동안 온 메시지 가져오기
- syncMissedMessages()
+
+ val wasInGracePeriod = disconnectJob?.isActive == true
+ disconnectJob?.cancel()
+ disconnectJob = null
+
+ if (!wasInGracePeriod) {
+ // 유예 시간 만료 또는 최초 연결 → 전체 재연결
+ connectChatRoomUseCase(chatRoomId)
+ syncMissedMessages()
+ }
+ }
+
+ fun scheduleDisconnect() {
+ disconnectJob?.cancel()
+ disconnectJob =
+ viewModelScope.launch {
+ Timber.w("[MEASURE] 연결 해제 유예 시작 (${DISCONNECT_GRACE_PERIOD_MS}ms)")
+ delay(DISCONNECT_GRACE_PERIOD_MS)
+ Timber.w("[MEASURE] 유예 시간 만료, 연결 해제")
+ disconnectChatRoomUseCase()
+ }
}
fun disconnect() {
+ disconnectJob?.cancel()
+ disconnectJob = null
disconnectChatRoomUseCase()
}
@@ -162,6 +186,7 @@ class ChattingRoomViewModel
fun syncMissedMessages() {
if (chatRoomId == DEFAULT_ID) return
viewModelScope.launch {
+ val startTime = System.currentTimeMillis()
when (val result = getNewChatMessagesUseCase(chatRoomId, lastChatId)) {
is BaseResult.Success -> {
if (result.data.messages.isNotEmpty()) {
@@ -177,6 +202,8 @@ class ChattingRoomViewModel
lastChatId = uniqueNewMessages.lastOrNull()?.chatId
updateAndEmitMessages(currentMessages + uniqueNewMessages)
}
+ } else {
+ Timber.w("[MEASURE] 누락 메시지 없음 (0건), 소요시간: ${System.currentTimeMillis() - startTime}ms")
}
}
@@ -237,5 +264,6 @@ class ChattingRoomViewModel
companion object {
const val DEFAULT_ID = -1L
const val LOAD_SIZE = 30 // 초기 로드 메시지 개수
+ private const val DISCONNECT_GRACE_PERIOD_MS = 5000L
}
}
diff --git a/feature/feed/build.gradle.kts b/feature/feed/build.gradle.kts
new file mode 100644
index 00000000..37571886
--- /dev/null
+++ b/feature/feed/build.gradle.kts
@@ -0,0 +1,55 @@
+plugins {
+ id("convention.android.library")
+ id("convention.android.hilt")
+ alias(libs.plugins.navigation.safeargs)
+}
+
+android {
+ namespace = "com.project200.undabang.feature.feed"
+
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ implementation(projects.domain)
+ implementation(projects.common)
+ implementation(projects.presentation)
+
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.google.android.material)
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.activity)
+
+ // Lifecycle
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.livedata.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+
+ // Navigation
+ implementation(libs.androidx.navigation.fragment.ktx)
+ implementation(libs.androidx.navigation.ui.ktx)
+
+ testImplementation(libs.androidx.arch.core.testing)
+ testImplementation(libs.turbine)
+ androidTestImplementation(libs.androidx.navigation.testing)
+
+ // Glide
+ implementation(libs.glide)
+ ksp(libs.glide.compiler.ksp)
+
+ // CircleImageView
+ implementation(libs.circleimageview)
+
+ // ViewPager2 Indicator
+ implementation(libs.circleindicator)
+
+ // Shimmer
+ implementation(libs.shimmer)
+
+ // SwipeRefreshLayout
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+}
diff --git a/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt b/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..b355346a
--- /dev/null
+++ b/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt
@@ -0,0 +1,16 @@
+package com.project200.feature.feed
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.project200.undabang.feature.feed.test", appContext.packageName)
+ }
+}
diff --git a/feature/feed/src/main/AndroidManifest.xml b/feature/feed/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/feed/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt
new file mode 100644
index 00000000..6a780f3b
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt
@@ -0,0 +1,59 @@
+package com.project200.undabang.feature.feed.detail
+
+import com.project200.domain.model.Comment
+import com.project200.domain.model.Reply
+import com.project200.domain.model.TaggedMember
+import java.time.LocalDateTime
+
+sealed class CommentItem {
+ abstract val commentId: Long
+ abstract val memberId: String
+ abstract val memberNickname: String
+ abstract val memberProfileImageUrl: String?
+ abstract val memberThumbnailUrl: String?
+ abstract val content: String
+ abstract val likesCount: Int
+ abstract val isLiked: Boolean
+ abstract val createdAt: LocalDateTime
+
+ data class CommentData(
+ val comment: Comment,
+ ) : CommentItem() {
+ override val commentId: Long = comment.commentId
+ override val memberId: String = comment.memberId
+ override val memberNickname: String = comment.memberNickname
+ override val memberProfileImageUrl: String? = comment.memberProfileImageUrl
+ override val memberThumbnailUrl: String? = comment.memberThumbnailUrl
+ override val content: String = comment.content
+ override val likesCount: Int = comment.likesCount
+ override val isLiked: Boolean = comment.isLiked
+ override val createdAt: LocalDateTime = comment.createdAt
+ }
+
+ data class ReplyData(
+ val reply: Reply,
+ val parentCommentId: Long,
+ ) : CommentItem() {
+ override val commentId: Long = reply.commentId
+ override val memberId: String = reply.memberId
+ override val memberNickname: String = reply.memberNickname
+ override val memberProfileImageUrl: String? = reply.memberProfileImageUrl
+ override val memberThumbnailUrl: String? = reply.memberThumbnailUrl
+ override val content: String = reply.content
+ override val likesCount: Int = reply.likesCount
+ override val isLiked: Boolean = reply.isLiked
+ override val createdAt: LocalDateTime = reply.createdAt
+ val taggedMember: TaggedMember? = reply.taggedMember
+ }
+}
+
+fun List.toCommentItems(): List {
+ val result = mutableListOf()
+ for (comment in this) {
+ result.add(CommentItem.CommentData(comment))
+ for (reply in comment.children) {
+ result.add(CommentItem.ReplyData(reply, comment.commentId))
+ }
+ }
+ return result
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt
new file mode 100644
index 00000000..7e98c742
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt
@@ -0,0 +1,241 @@
+package com.project200.undabang.feature.feed.detail
+
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.project200.presentation.utils.RelativeTimeUtil
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.ItemCommentBinding
+import com.project200.undabang.feature.feed.databinding.ItemReplyBinding
+
+class CommentRVAdapter(
+ private val currentMemberId: String?,
+ private val onLikeClick: (CommentItem) -> Unit,
+ private val onReplyClick: (CommentItem) -> Unit,
+ private val onMoreClick: (CommentItem) -> Unit,
+) : ListAdapter(CommentDiffCallback()) {
+ override fun getItemViewType(position: Int): Int {
+ return when (getItem(position)) {
+ is CommentItem.CommentData -> VIEW_TYPE_COMMENT
+ is CommentItem.ReplyData -> VIEW_TYPE_REPLY
+ }
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): RecyclerView.ViewHolder {
+ return when (viewType) {
+ VIEW_TYPE_COMMENT -> {
+ val binding =
+ ItemCommentBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ CommentViewHolder(binding)
+ }
+ VIEW_TYPE_REPLY -> {
+ val binding =
+ ItemReplyBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ ReplyViewHolder(binding)
+ }
+ else -> throw IllegalArgumentException("Unknown view type: $viewType")
+ }
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ ) {
+ when (val item = getItem(position)) {
+ is CommentItem.CommentData -> (holder as CommentViewHolder).bind(item)
+ is CommentItem.ReplyData -> (holder as ReplyViewHolder).bind(item)
+ }
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ payloads: MutableList,
+ ) {
+ if (payloads.isEmpty()) {
+ super.onBindViewHolder(holder, position, payloads)
+ return
+ }
+
+ val payload =
+ payloads.firstOrNull() as? Set<*> ?: run {
+ super.onBindViewHolder(holder, position, payloads)
+ return
+ }
+
+ if (PAYLOAD_LIKE in payload) {
+ val item = getItem(position)
+ when (holder) {
+ is CommentViewHolder -> holder.bindLike(item)
+ is ReplyViewHolder -> holder.bindLike(item)
+ }
+ }
+ }
+
+ inner class CommentViewHolder(
+ private val binding: ItemCommentBinding,
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: CommentItem.CommentData) {
+ with(binding) {
+ nicknameTv.text = item.memberNickname
+ timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt)
+ timeTv.visibility = View.VISIBLE
+ contentTv.text = item.content
+ likeCountTv.text = item.likesCount.toString()
+
+ val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl
+ Glide.with(root.context)
+ .load(profileUrl)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .circleCrop()
+ .into(profileIv)
+
+ val likeIcon =
+ if (item.isLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ }
+ likeIv.setImageResource(likeIcon)
+
+ val isMyComment = currentMemberId != null && item.memberId == currentMemberId
+ moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE
+
+ likeIv.setOnClickListener { onLikeClick(item) }
+ replyBtn.setOnClickListener { onReplyClick(item) }
+ moreIv.setOnClickListener { onMoreClick(item) }
+ }
+ }
+
+ fun bindLike(item: CommentItem) {
+ with(binding) {
+ likeCountTv.text = item.likesCount.toString()
+ val likeIcon =
+ if (item.isLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ }
+ likeIv.setImageResource(likeIcon)
+ }
+ }
+ }
+
+ inner class ReplyViewHolder(
+ private val binding: ItemReplyBinding,
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: CommentItem.ReplyData) {
+ with(binding) {
+ nicknameTv.text = item.memberNickname
+ timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt)
+ timeTv.visibility = View.VISIBLE
+ likeCountTv.text = item.likesCount.toString()
+
+ if (item.taggedMember != null) {
+ val tag = "@${item.taggedMember.memberNickname} "
+ val spannable = SpannableString("$tag${item.content}")
+ val tagColor = ContextCompat.getColor(root.context, R.color.tag)
+ spannable.setSpan(
+ ForegroundColorSpan(tagColor),
+ 0,
+ tag.length,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
+ )
+ contentTv.text = spannable
+ } else {
+ contentTv.text = item.content
+ }
+
+ val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl
+ Glide.with(root.context)
+ .load(profileUrl)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .circleCrop()
+ .into(profileIv)
+
+ likeIv.setImageResource(
+ if (item.isLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ },
+ )
+
+ val isMyComment = currentMemberId != null && item.memberId == currentMemberId
+ moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE
+
+ likeIv.setOnClickListener { onLikeClick(item) }
+ replyBtn.setOnClickListener { onReplyClick(item) }
+ moreIv.setOnClickListener { onMoreClick(item) }
+ }
+ }
+
+ fun bindLike(item: CommentItem) {
+ with(binding) {
+ likeCountTv.text = item.likesCount.toString()
+ val likeIcon =
+ if (item.isLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ }
+ likeIv.setImageResource(likeIcon)
+ }
+ }
+ }
+
+ class CommentDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: CommentItem,
+ newItem: CommentItem,
+ ): Boolean {
+ return oldItem.commentId == newItem.commentId &&
+ oldItem::class == newItem::class
+ }
+
+ override fun areContentsTheSame(
+ oldItem: CommentItem,
+ newItem: CommentItem,
+ ): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun getChangePayload(
+ oldItem: CommentItem,
+ newItem: CommentItem,
+ ): Any? {
+ val payloads = mutableSetOf()
+ if (oldItem.isLiked != newItem.isLiked || oldItem.likesCount != newItem.likesCount) {
+ payloads.add(PAYLOAD_LIKE)
+ }
+ return payloads.ifEmpty { null }
+ }
+ }
+
+ companion object {
+ private const val VIEW_TYPE_COMMENT = 0
+ private const val VIEW_TYPE_REPLY = 1
+ const val PAYLOAD_LIKE = "payload_like"
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt
new file mode 100644
index 00000000..16d50b9a
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt
@@ -0,0 +1,261 @@
+package com.project200.undabang.feature.feed.detail
+
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.bumptech.glide.Glide
+import com.project200.domain.model.Feed
+import com.project200.presentation.base.BindingFragment
+import com.project200.presentation.utils.RelativeTimeUtil
+import com.project200.presentation.utils.collectFlow
+import com.project200.presentation.utils.collectToast
+import com.project200.presentation.view.MenuBottomSheetDialog
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.FragmentFeedDetailBinding
+import com.project200.undabang.feature.feed.form.FeedFormFragment
+import com.project200.undabang.feature.feed.list.FeedListFragment
+import com.project200.undabang.feature.feed.list.ImageRVAdapter
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class FeedDetailFragment : BindingFragment(R.layout.fragment_feed_detail) {
+ private val viewModel: FeedDetailViewModel by viewModels()
+ private val args: FeedDetailFragmentArgs by navArgs()
+ private var commentRVAdapter: CommentRVAdapter? = null
+
+ override fun getViewBinding(view: View): FragmentFeedDetailBinding {
+ return FragmentFeedDetailBinding.bind(view)
+ }
+
+ override fun setupViews() {
+ viewModel.setFeedId(args.feedId)
+ initToolbar()
+ initCommentInput()
+ initObserver()
+ observeFeedUpdated()
+ }
+
+ private fun observeFeedUpdated() {
+ findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData(FeedFormFragment.FEED_UPDATED_KEY)
+ ?.observe(viewLifecycleOwner) { updated ->
+ if (updated) {
+ viewModel.refreshFeed()
+ findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true)
+ findNavController().currentBackStackEntry?.savedStateHandle?.remove(FeedFormFragment.FEED_UPDATED_KEY)
+ }
+ }
+ }
+
+ private fun initToolbar() {
+ binding.baseToolbar.apply {
+ setTitle("")
+ showBackButton(true) { findNavController().navigateUp() }
+ }
+ }
+
+ private fun initCommentInput() {
+ with(binding.commentInputLayout) {
+ commentInputEt.addTextChangedListener(
+ object : TextWatcher {
+ override fun beforeTextChanged(
+ s: CharSequence?,
+ start: Int,
+ count: Int,
+ after: Int,
+ ) {}
+
+ override fun onTextChanged(
+ s: CharSequence?,
+ start: Int,
+ before: Int,
+ count: Int,
+ ) {}
+
+ override fun afterTextChanged(s: Editable?) {
+ val hasText = !s.isNullOrBlank()
+ val sendIcon =
+ if (hasText) {
+ R.drawable.ic_send
+ } else {
+ R.drawable.ic_send_unable
+ }
+ sendBtn.setImageResource(sendIcon)
+ }
+ },
+ )
+
+ sendBtn.setOnClickListener {
+ val content = commentInputEt.text.toString()
+ if (content.isNotBlank()) {
+ viewModel.createComment(content)
+ commentInputEt.text.clear()
+ }
+ }
+
+ cancelReplyIv.setOnClickListener {
+ viewModel.setReplyTarget(null)
+ }
+ }
+ }
+
+ private fun initObserver() {
+ viewModel.feed.observe(viewLifecycleOwner) { feed ->
+ bindFeedData(feed)
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
+ if (isLoading) {
+ binding.shimmerLayout.visibility = View.VISIBLE
+ binding.shimmerLayout.startShimmer()
+ binding.scrollView.visibility = View.GONE
+ } else {
+ binding.shimmerLayout.stopShimmer()
+ binding.shimmerLayout.visibility = View.GONE
+ binding.scrollView.visibility = View.VISIBLE
+ }
+ }
+
+ collectToast(viewModel.toastEvent)
+
+ collectFlow(viewModel.feedDeleted) {
+ findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true)
+ findNavController().navigateUp()
+ }
+
+ collectFlow(viewModel.feedLoadError) {
+ binding.errorTv.visibility = View.VISIBLE
+ binding.scrollView.visibility = View.GONE
+ }
+
+ viewModel.isMyFeed.observe(viewLifecycleOwner) { isMyFeed ->
+ binding.moreIv.visibility = if (isMyFeed) View.VISIBLE else View.GONE
+ }
+
+ viewModel.comments.observe(viewLifecycleOwner) { comments ->
+ setupCommentAdapter()
+ val items = comments.toCommentItems()
+ commentRVAdapter?.submitList(items)
+
+ binding.noCommentsTv.visibility = if (comments.isEmpty()) View.VISIBLE else View.GONE
+ binding.commentsRv.visibility = if (comments.isEmpty()) View.GONE else View.VISIBLE
+ }
+
+ viewModel.replyTarget.observe(viewLifecycleOwner) { target ->
+ with(binding.commentInputLayout) {
+ if (target != null) {
+ replyTargetTv.text = getString(R.string.feed_reply_writing, target.memberNickname)
+ replyTargetTv.visibility = View.VISIBLE
+ cancelReplyIv.visibility = View.VISIBLE
+ } else {
+ replyTargetTv.visibility = View.GONE
+ cancelReplyIv.visibility = View.GONE
+ }
+ }
+ }
+ }
+
+ private fun setupCommentAdapter() {
+ if (commentRVAdapter != null) return
+
+ val currentMemberId = viewModel.currentMemberId.value
+ commentRVAdapter =
+ CommentRVAdapter(
+ currentMemberId = currentMemberId,
+ onLikeClick = { item -> viewModel.toggleCommentLike(item) },
+ onReplyClick = { item -> viewModel.setReplyTarget(item) },
+ onMoreClick = { item -> showCommentMenuBottomSheet(item) },
+ )
+ binding.commentsRv.adapter = commentRVAdapter
+ }
+
+ private fun showCommentMenuBottomSheet(item: CommentItem) {
+ MenuBottomSheetDialog(
+ onDeleteClicked = {
+ viewModel.deleteComment(item.commentId)
+ },
+ showEditButton = false,
+ ).show(parentFragmentManager, "CommentMenu")
+ }
+
+ private fun bindFeedData(feed: Feed) {
+ with(binding) {
+ nicknameTv.text = feed.nickname
+ timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt)
+ contentTv.text = feed.feedContent
+ likeCountTv.text = feed.feedLikesCount.toString()
+ commentCountTv.text = feed.feedCommentsCount.toString()
+
+ val likeIcon =
+ if (feed.feedIsLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ }
+ likeIv.setImageResource(likeIcon)
+
+ likeIv.setOnClickListener {
+ viewModel.toggleFeedLike()
+ }
+
+ val hasType = !feed.feedTypeName.isNullOrBlank()
+ arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE
+ feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE
+ if (hasType) {
+ feedTypeTv.text = feed.feedTypeName
+ }
+
+ Glide.with(profileIv.context)
+ .load(feed.profileUrl)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .circleCrop()
+ .into(profileIv)
+
+ if (feed.feedPictures.isNotEmpty()) {
+ imagesRv.visibility = View.VISIBLE
+ imagesRv.layoutManager =
+ LinearLayoutManager(
+ context,
+ LinearLayoutManager.HORIZONTAL,
+ false,
+ )
+ imagesRv.adapter = ImageRVAdapter(feed.feedPictures)
+ } else {
+ imagesRv.visibility = View.GONE
+ }
+
+ moreIv.setOnClickListener {
+ showMenuBottomSheet()
+ }
+ }
+ }
+
+ private fun showMenuBottomSheet() {
+ MenuBottomSheetDialog(
+ onEditClicked = {
+ navigateToEditFeed()
+ },
+ onDeleteClicked = {
+ viewModel.deleteFeed()
+ },
+ ).show(parentFragmentManager, MenuBottomSheetDialog::class.java.simpleName)
+ }
+
+ private fun navigateToEditFeed() {
+ val feed = viewModel.feed.value ?: return
+ val action =
+ FeedDetailFragmentDirections.actionFeedDetailFragmentToFeedFormFragment(
+ feedId = feed.feedId,
+ )
+ findNavController().navigate(action)
+ }
+
+ override fun onDestroyView() {
+ commentRVAdapter = null
+ super.onDestroyView()
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt
new file mode 100644
index 00000000..f0fedb3a
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt
@@ -0,0 +1,350 @@
+package com.project200.undabang.feature.feed.detail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Comment
+import com.project200.domain.model.Feed
+import com.project200.domain.usecase.CreateCommentUseCase
+import com.project200.domain.usecase.DeleteCommentUseCase
+import com.project200.domain.usecase.DeleteFeedUseCase
+import com.project200.domain.usecase.GetCommentsUseCase
+import com.project200.domain.usecase.GetFeedDetailUseCase
+import com.project200.domain.usecase.GetMemberIdUseCase
+import com.project200.domain.usecase.LikeCommentUseCase
+import com.project200.domain.usecase.LikeFeedUseCase
+import com.project200.presentation.utils.UiText
+import com.project200.undabang.feature.feed.R
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FeedDetailViewModel
+ @Inject
+ constructor(
+ private val getFeedDetailUseCase: GetFeedDetailUseCase,
+ private val getMemberIdUseCase: GetMemberIdUseCase,
+ private val deleteFeedUseCase: DeleteFeedUseCase,
+ private val getCommentsUseCase: GetCommentsUseCase,
+ private val createCommentUseCase: CreateCommentUseCase,
+ private val likeCommentUseCase: LikeCommentUseCase,
+ private val deleteCommentUseCase: DeleteCommentUseCase,
+ private val likeFeedUseCase: LikeFeedUseCase,
+ ) : ViewModel() {
+ private var feedId: Long = -1L
+
+ private val _feed = MutableLiveData()
+ val feed: LiveData get() = _feed
+
+ private val _isLoading = MutableLiveData(false)
+ val isLoading: LiveData get() = _isLoading
+
+ private val _toastEvent = MutableSharedFlow()
+ val toastEvent: SharedFlow = _toastEvent.asSharedFlow()
+
+ private val _feedDeleted = MutableSharedFlow()
+ val feedDeleted: SharedFlow = _feedDeleted.asSharedFlow()
+
+ private val _feedLoadError = MutableSharedFlow()
+ val feedLoadError: SharedFlow = _feedLoadError.asSharedFlow()
+
+ private val _currentMemberId = MutableLiveData()
+ val currentMemberId: LiveData get() = _currentMemberId
+
+ private val _isMyFeed = MutableLiveData(false)
+ val isMyFeed: LiveData get() = _isMyFeed
+
+ private val _comments = MutableLiveData>()
+ val comments: LiveData> get() = _comments
+
+ private val _commentsLoading = MutableLiveData(false)
+ val commentsLoading: LiveData get() = _commentsLoading
+
+ private val _replyTarget = MutableLiveData()
+ val replyTarget: LiveData get() = _replyTarget
+
+ private val commentLikeJobs = mutableMapOf()
+ private val pendingCommentLikes = mutableMapOf()
+
+ private var feedLikeJob: Job? = null
+ private var pendingFeedLike: Boolean? = null
+ private var originalFeedLikeState: Boolean? = null
+
+ fun setFeedId(feedId: Long) {
+ this.feedId = feedId
+ loadCurrentMemberId()
+ loadFeedDetail()
+ loadComments()
+ }
+
+ private fun loadCurrentMemberId() {
+ viewModelScope.launch {
+ _currentMemberId.value = getMemberIdUseCase()
+ }
+ }
+
+ private fun loadFeedDetail() {
+ if (feedId == -1L) {
+ viewModelScope.launch {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_load_error))
+ _feedLoadError.emit(Unit)
+ }
+ return
+ }
+
+ _isLoading.value = true
+ viewModelScope.launch {
+ when (val result = getFeedDetailUseCase(feedId)) {
+ is BaseResult.Success -> {
+ _feed.value = result.data
+ checkIsMyFeed(result.data.memberId)
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_load_error))
+ _feedLoadError.emit(Unit)
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+
+ private fun checkIsMyFeed(feedMemberId: String) {
+ val currentId = _currentMemberId.value
+ _isMyFeed.value = currentId != null && currentId == feedMemberId
+ }
+
+ fun refreshFeed() {
+ loadFeedDetail()
+ loadComments()
+ }
+
+ fun deleteFeed() {
+ viewModelScope.launch {
+ when (deleteFeedUseCase(feedId)) {
+ is BaseResult.Success -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_deleted))
+ _feedDeleted.emit(Unit)
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_delete_error))
+ }
+ }
+ }
+ }
+
+ fun loadComments() {
+ if (feedId == -1L) return
+
+ _commentsLoading.value = true
+ viewModelScope.launch {
+ when (val result = getCommentsUseCase(feedId)) {
+ is BaseResult.Success -> {
+ _comments.value = result.data
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.comment_load_error))
+ }
+ }
+ _commentsLoading.value = false
+ }
+ }
+
+ fun createComment(content: String) {
+ if (content.isBlank() || feedId == -1L) return
+
+ viewModelScope.launch {
+ val target = _replyTarget.value
+ val parentCommentId =
+ when (target) {
+ is CommentItem.CommentData -> target.commentId
+ is CommentItem.ReplyData -> target.parentCommentId
+ null -> null
+ }
+ // 대댓글에 답글 달 때만 태그 추가 (댓글에 답글 달 때는 태그 X)
+ val taggedMemberId =
+ when (target) {
+ is CommentItem.ReplyData -> target.memberId
+ else -> null
+ }
+ when (createCommentUseCase(feedId, content, parentCommentId, taggedMemberId)) {
+ is BaseResult.Success -> {
+ _toastEvent.emit(UiText.StringResource(R.string.comment_created))
+ _replyTarget.value = null
+ loadComments()
+ refreshFeedCommentsCount()
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.comment_create_error))
+ }
+ }
+ }
+ }
+
+ fun setReplyTarget(target: CommentItem?) {
+ _replyTarget.value = target
+ }
+
+ // 좋아요 토글 시 로컬 상태 즉시 업데이트 + 1초 딜레이 후 서버 반영 (중복 요청 방지)
+ fun toggleCommentLike(item: CommentItem) {
+ val commentId = item.commentId
+ val currentLikedState = pendingCommentLikes[commentId] ?: item.isLiked
+ val newLikedState = !currentLikedState
+
+ pendingCommentLikes[commentId] = newLikedState
+ updateCommentLikeLocally(commentId, newLikedState)
+
+ commentLikeJobs[commentId]?.cancel()
+ commentLikeJobs[commentId] =
+ viewModelScope.launch {
+ delay(LIKE_DEBOUNCE_MS)
+
+ val finalLikedState = pendingCommentLikes[commentId] ?: return@launch
+ if (finalLikedState == item.isLiked) {
+ pendingCommentLikes.remove(commentId)
+ return@launch
+ }
+
+ when (likeCommentUseCase(commentId, finalLikedState)) {
+ is BaseResult.Success -> {
+ pendingCommentLikes.remove(commentId)
+ }
+ is BaseResult.Error -> {
+ pendingCommentLikes.remove(commentId)
+ updateCommentLikeLocally(commentId, item.isLiked)
+ _toastEvent.emit(UiText.StringResource(R.string.like_error))
+ }
+ }
+ }
+ }
+
+ // 좋아요 상태를 업데이트할 수 있도록 수정
+ private fun updateCommentLikeLocally(
+ commentId: Long,
+ isLiked: Boolean,
+ ) {
+ val currentComments = _comments.value ?: return
+ val updatedComments =
+ currentComments.map { comment ->
+ if (comment.commentId == commentId) {
+ comment.copy(
+ isLiked = isLiked,
+ likesCount = if (isLiked) comment.likesCount + 1 else comment.likesCount - 1,
+ )
+ } else {
+ val updatedChildren =
+ comment.children.map { reply ->
+ if (reply.commentId == commentId) {
+ reply.copy(
+ isLiked = isLiked,
+ likesCount = if (isLiked) reply.likesCount + 1 else reply.likesCount - 1,
+ )
+ } else {
+ reply
+ }
+ }
+ comment.copy(children = updatedChildren)
+ }
+ }
+ _comments.value = updatedComments
+ }
+
+ fun deleteComment(commentId: Long) {
+ viewModelScope.launch {
+ when (deleteCommentUseCase(commentId)) {
+ is BaseResult.Success -> {
+ _toastEvent.emit(UiText.StringResource(R.string.comment_deleted))
+ loadComments()
+ refreshFeedCommentsCount()
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.comment_delete_error))
+ }
+ }
+ }
+ }
+
+ private fun refreshFeedCommentsCount() {
+ viewModelScope.launch {
+ when (val result = getFeedDetailUseCase(feedId)) {
+ is BaseResult.Success -> {
+ _feed.value = result.data
+ }
+ is BaseResult.Error -> { }
+ }
+ }
+ }
+
+ // 좋아요 토글 시 로컬 상태 즉시 업데이트 + 1초 딜레이 후 서버 반영 (중복 요청 방지)
+ fun toggleFeedLike() {
+ val currentFeed = _feed.value ?: return
+
+ if (originalFeedLikeState == null) {
+ originalFeedLikeState = currentFeed.feedIsLiked
+ }
+
+ val currentLikedState = pendingFeedLike ?: currentFeed.feedIsLiked
+ val newLikedState = !currentLikedState
+
+ pendingFeedLike = newLikedState
+ updateFeedLikeLocally(newLikedState)
+
+ feedLikeJob?.cancel()
+ feedLikeJob =
+ viewModelScope.launch {
+ delay(LIKE_DEBOUNCE_MS)
+
+ val finalLikedState = pendingFeedLike ?: return@launch
+ val originalState = originalFeedLikeState ?: return@launch
+
+ if (finalLikedState == originalState) {
+ resetFeedLikePendingState()
+ return@launch
+ }
+
+ when (likeFeedUseCase(feedId, finalLikedState)) {
+ is BaseResult.Success -> {
+ resetFeedLikePendingState()
+ }
+ is BaseResult.Error -> {
+ resetFeedLikePendingState()
+ updateFeedLikeLocally(originalState)
+ _toastEvent.emit(UiText.StringResource(R.string.like_error))
+ }
+ }
+ }
+ }
+
+ // 좋아요 상태 변경이 완료되거나 취소된 후에 pending 상태 초기화
+ private fun resetFeedLikePendingState() {
+ pendingFeedLike = null
+ originalFeedLikeState = null
+ }
+
+ private fun updateFeedLikeLocally(isLiked: Boolean) {
+ val currentFeed = _feed.value ?: return
+ if (currentFeed.feedIsLiked == isLiked) return
+
+ _feed.value =
+ currentFeed.copy(
+ feedIsLiked = isLiked,
+ feedLikesCount =
+ if (isLiked) {
+ currentFeed.feedLikesCount + 1
+ } else {
+ currentFeed.feedLikesCount - 1
+ },
+ )
+ }
+
+ companion object {
+ private const val LIKE_DEBOUNCE_MS = 1000L
+ }
+ }
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt
new file mode 100644
index 00000000..833ec17f
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt
@@ -0,0 +1,163 @@
+package com.project200.undabang.feature.feed.form
+
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import com.bumptech.glide.Glide
+import com.project200.domain.model.PreferredExercise
+import com.project200.presentation.base.BindingFragment
+import com.project200.presentation.utils.collectToast
+import com.project200.presentation.view.SelectionBottomSheetDialog
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.FragmentFeedFormBinding
+import com.project200.undabang.feature.feed.list.FeedListFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class FeedFormFragment : BindingFragment(R.layout.fragment_feed_form) {
+ private val viewModel: FeedFormViewModel by viewModels()
+ private val args: FeedFormFragmentArgs by navArgs()
+ private val imageAdapter =
+ FeedFormImageAdapter(
+ onDeleteExistingClick = { imageId -> viewModel.removeExistingImage(imageId) },
+ onDeleteNewClick = { uri -> viewModel.removeImage(uri) },
+ )
+
+ private val pickImagesLauncher =
+ registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
+ if (uris.isNotEmpty()) {
+ viewModel.addImages(uris)
+ }
+ }
+
+ override fun getViewBinding(view: View): FragmentFeedFormBinding {
+ return FragmentFeedFormBinding.bind(view)
+ }
+
+ override fun setupViews() {
+ viewModel.initData(feedId = args.feedId)
+ initToolbar()
+ initView()
+ initObserver()
+ }
+
+ private fun initToolbar() {
+ viewModel.isEditMode.observe(viewLifecycleOwner) { isEditMode ->
+ val title =
+ if (isEditMode) {
+ getString(R.string.feed_form_edit_title)
+ } else {
+ getString(R.string.feed_form_title)
+ }
+ binding.baseToolbar.setTitle(title)
+ }
+ binding.baseToolbar.showBackButton(true) { findNavController().navigateUp() }
+ binding.completeBtn.setOnClickListener {
+ viewModel.submitFeed(binding.contentEt.text.toString())
+ }
+ }
+
+ private fun initView() {
+ binding.imageListRv.adapter = imageAdapter
+
+ binding.addImageBtn.setOnClickListener {
+ pickImagesLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ }
+
+ binding.dabangSelectionTv.setOnClickListener {
+ showDabangSelection()
+ }
+
+ binding.arrowIv.setOnClickListener {
+ showDabangSelection()
+ }
+ }
+
+ private fun showDabangSelection() {
+ viewModel.requestShowDabangSelection()
+ }
+
+ private fun updateImageList() {
+ val existingImages = viewModel.registeredImages.value ?: emptyList()
+ val newImages = viewModel.selectedImages.value ?: emptyList()
+ imageAdapter.submitList(existingImages, newImages)
+ }
+
+ private fun displayDabangSelection(types: List) {
+ val names = types.map { it.name }
+
+ SelectionBottomSheetDialog(names) { selectedName ->
+ val selected = types.find { it.name == selectedName }
+ viewModel.selectType(selected)
+ binding.dabangSelectionTv.text = selectedName
+ binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null))
+ }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name)
+ viewModel.onDabangSelectionShown()
+ }
+
+ private fun initObserver() {
+ viewModel.userProfile.observe(viewLifecycleOwner) { profile ->
+ binding.nicknameTv.text = profile.nickname
+ Glide.with(this)
+ .load(profile.profileImageUrl)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .circleCrop()
+ .into(binding.profileIv)
+ }
+
+ viewModel.selectedImages.observe(viewLifecycleOwner) { _ ->
+ updateImageList()
+ }
+
+ viewModel.registeredImages.observe(viewLifecycleOwner) { _ ->
+ updateImageList()
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
+ binding.loadingPb.visibility = if (isLoading) View.VISIBLE else View.GONE
+ }
+
+ viewModel.createSuccess.observe(viewLifecycleOwner) {
+ Toast.makeText(context, R.string.feed_form_create_success, Toast.LENGTH_SHORT).show()
+ findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true)
+ findNavController().popBackStack()
+ }
+
+ viewModel.updateSuccess.observe(viewLifecycleOwner) { success ->
+ if (success) {
+ Toast.makeText(context, R.string.feed_form_update_success, Toast.LENGTH_SHORT).show()
+ findNavController().previousBackStackEntry?.savedStateHandle?.set(FEED_UPDATED_KEY, true)
+ findNavController().popBackStack()
+ }
+ }
+
+ collectToast(viewModel.toastEvent)
+
+ viewModel.showDabangSelection.observe(viewLifecycleOwner) { types ->
+ if (!types.isNullOrEmpty()) {
+ displayDabangSelection(types)
+ }
+ }
+
+ viewModel.initialContentForEdit.observe(viewLifecycleOwner) { content ->
+ if (!content.isNullOrEmpty()) {
+ binding.contentEt.setText(content)
+ }
+ }
+
+ viewModel.selectedType.observe(viewLifecycleOwner) { type ->
+ if (type != null) {
+ binding.dabangSelectionTv.text = type.name
+ binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null))
+ }
+ }
+ }
+
+ companion object {
+ const val FEED_UPDATED_KEY = "feed_updated"
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt
new file mode 100644
index 00000000..3a23efff
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt
@@ -0,0 +1,72 @@
+package com.project200.undabang.feature.feed.form
+
+import android.net.Uri
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.ItemFeedFormImageBinding
+
+class FeedFormImageAdapter(
+ private val onDeleteExistingClick: (Long) -> Unit,
+ private val onDeleteNewClick: (Uri) -> Unit,
+) : RecyclerView.Adapter() {
+ private val items = mutableListOf()
+
+ fun submitList(
+ registeredImages: List,
+ newImages: List,
+ ) {
+ items.clear()
+ items.addAll(registeredImages.map { FeedFormImageItem.Existing(it.imageId, it.imageUrl) })
+ items.addAll(newImages.map { FeedFormImageItem.New(it) })
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): ViewHolder {
+ val binding =
+ ItemFeedFormImageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(
+ holder: ViewHolder,
+ position: Int,
+ ) {
+ holder.bind(items[position])
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ inner class ViewHolder(private val binding: ItemFeedFormImageBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: FeedFormImageItem) {
+ val imageSource: Any =
+ when (item) {
+ is FeedFormImageItem.Existing -> item.imageUrl
+ is FeedFormImageItem.New -> item.uri
+ }
+
+ Glide.with(binding.formImageIv.context)
+ .load(imageSource)
+ .placeholder(R.drawable.ic_feed_image_placeholder)
+ .fitCenter()
+ .into(binding.formImageIv)
+
+ binding.deleteIv.setOnClickListener {
+ when (item) {
+ is FeedFormImageItem.Existing -> onDeleteExistingClick(item.imageId)
+ is FeedFormImageItem.New -> onDeleteNewClick(item.uri)
+ }
+ }
+ }
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt
new file mode 100644
index 00000000..e45adb94
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt
@@ -0,0 +1,255 @@
+package com.project200.undabang.feature.feed.form
+
+import android.net.Uri
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.CreateFeedModel
+import com.project200.domain.model.PreferredExercise
+import com.project200.domain.model.UpdateFeedModel
+import com.project200.domain.model.UserProfile
+import com.project200.domain.usecase.CreateFeedUseCase
+import com.project200.domain.usecase.DeleteFeedImageUseCase
+import com.project200.domain.usecase.GetFeedDetailUseCase
+import com.project200.domain.usecase.GetPreferredExerciseTypesUseCase
+import com.project200.domain.usecase.GetPreferredExerciseUseCase
+import com.project200.domain.usecase.GetUserProfileUseCase
+import com.project200.domain.usecase.UpdateFeedUseCase
+import com.project200.domain.usecase.UploadFeedImagesUseCase
+import com.project200.presentation.utils.UiText
+import com.project200.undabang.feature.feed.R
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FeedFormViewModel
+ @Inject
+ constructor(
+ private val getUserProfileUseCase: GetUserProfileUseCase,
+ private val getPreferredExerciseUseCase: GetPreferredExerciseUseCase,
+ private val getPreferredExerciseTypesUseCase: GetPreferredExerciseTypesUseCase,
+ private val getFeedDetailUseCase: GetFeedDetailUseCase,
+ private val createFeedUseCase: CreateFeedUseCase,
+ private val updateFeedUseCase: UpdateFeedUseCase,
+ private val uploadFeedImagesUseCase: UploadFeedImagesUseCase,
+ private val deleteFeedImageUseCase: DeleteFeedImageUseCase,
+ ) : ViewModel() {
+ private var feedId: Long = -1L
+
+ private val _isEditMode = MutableLiveData(false)
+ val isEditMode: LiveData get() = _isEditMode
+
+ private val _userProfile = MutableLiveData()
+ val userProfile: LiveData get() = _userProfile
+
+ private val _exerciseTypes = MutableLiveData>()
+ val exerciseTypes: LiveData> get() = _exerciseTypes
+
+ private val _selectedType = MutableLiveData()
+ val selectedType: LiveData get() = _selectedType
+
+ private val _selectedImages = MutableLiveData>(emptyList())
+ val selectedImages: LiveData> get() = _selectedImages
+
+ private val _registeredImages = MutableLiveData>(emptyList())
+ val registeredImages: LiveData> get() = _registeredImages
+
+ private val deletedImageIds = mutableListOf()
+
+ private val _isLoading = MutableLiveData()
+ val isLoading: LiveData get() = _isLoading
+
+ private val _createSuccess = MutableLiveData()
+ val createSuccess: LiveData get() = _createSuccess
+
+ private val _updateSuccess = MutableLiveData()
+ val updateSuccess: LiveData get() = _updateSuccess
+
+ private val _toastEvent = MutableSharedFlow()
+ val toastEvent: SharedFlow = _toastEvent.asSharedFlow()
+
+ private val _showDabangSelection = MutableLiveData?>()
+ val showDabangSelection: LiveData?> get() = _showDabangSelection
+
+ private val _initialContentForEdit = MutableLiveData()
+ val initialContentForEdit: LiveData get() = _initialContentForEdit
+
+ fun initData(feedId: Long = -1L) {
+ this.feedId = feedId
+ _isEditMode.value = feedId != -1L
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ when (val result = getUserProfileUseCase()) {
+ is BaseResult.Success -> _userProfile.value = result.data
+ is BaseResult.Error -> _toastEvent.emit(UiText.StringResource(R.string.profile_load_error))
+ }
+
+ val preferredResult = getPreferredExerciseUseCase()
+ val allTypesResult = getPreferredExerciseTypesUseCase()
+
+ val preferredList = if (preferredResult is BaseResult.Success) preferredResult.data else emptyList()
+ val allList = if (allTypesResult is BaseResult.Success) allTypesResult.data else emptyList()
+
+ val combined = (preferredList + allList).distinctBy { it.exerciseTypeId }
+ _exerciseTypes.value = combined
+
+ if (_isEditMode.value == true) {
+ loadFeedForEdit(combined)
+ }
+ }
+ }
+
+ private suspend fun loadFeedForEdit(exerciseTypes: List) {
+ when (val result = getFeedDetailUseCase(feedId)) {
+ is BaseResult.Success -> {
+ val feed = result.data
+ _initialContentForEdit.value = feed.feedContent
+
+ val existingList =
+ feed.feedPictures.map { picture ->
+ RegisteredImage(picture.feedPictureId, picture.feedPictureUrl)
+ }
+ _registeredImages.value = existingList
+
+ val typeId = feed.feedTypeId
+ val typeName = feed.feedTypeName
+ if (typeId != null && typeName != null) {
+ val matchedType =
+ exerciseTypes.find { it.exerciseTypeId == typeId }
+ ?: PreferredExercise(
+ preferredExerciseId = -1L,
+ exerciseTypeId = typeId,
+ name = typeName,
+ skillLevel = "",
+ daysOfWeek = List(7) { false },
+ imageUrl = null,
+ )
+ _selectedType.value = matchedType
+ }
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_load_error))
+ }
+ }
+ }
+
+ fun selectType(exercise: PreferredExercise?) {
+ _selectedType.value = exercise
+ }
+
+ fun requestShowDabangSelection() {
+ val types = _exerciseTypes.value
+ if (!types.isNullOrEmpty()) {
+ _showDabangSelection.value = types
+ }
+ }
+
+ fun onDabangSelectionShown() {
+ _showDabangSelection.value = null
+ }
+
+ fun addImages(uris: List) {
+ val current = _selectedImages.value ?: emptyList()
+ _selectedImages.value = current + uris
+ }
+
+ fun removeImage(uri: Uri) {
+ val current = _selectedImages.value ?: emptyList()
+ _selectedImages.value = current.filter { it != uri }
+ }
+
+ fun removeExistingImage(imageId: Long) {
+ deletedImageIds.add(imageId)
+ val current = _registeredImages.value ?: emptyList()
+ _registeredImages.value = current.filter { it.imageId != imageId }
+ }
+
+ fun submitFeed(content: String) {
+ if (content.isBlank()) {
+ viewModelScope.launch {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_form_empty_content_warning))
+ }
+ return
+ }
+
+ _isLoading.value = true
+ viewModelScope.launch {
+ if (_isEditMode.value == true) {
+ val model =
+ UpdateFeedModel(
+ feedId = feedId,
+ feedContent = content,
+ feedTypeId = _selectedType.value?.exerciseTypeId,
+ )
+ when (updateFeedUseCase(model)) {
+ is BaseResult.Success -> {
+ var hasImageError = false
+
+ deletedImageIds.forEach { imageId ->
+ when (deleteFeedImageUseCase(feedId, imageId)) {
+ is BaseResult.Error -> hasImageError = true
+ is BaseResult.Success -> {}
+ }
+ }
+
+ val newImages = _selectedImages.value ?: emptyList()
+ if (newImages.isNotEmpty()) {
+ val imageUriStrings = newImages.map { it.toString() }
+ when (uploadFeedImagesUseCase(feedId, imageUriStrings)) {
+ is BaseResult.Error -> hasImageError = true
+ is BaseResult.Success -> {}
+ }
+ }
+
+ if (hasImageError) {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error))
+ }
+ _updateSuccess.value = true
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_form_update_error))
+ }
+ }
+ } else {
+ val model =
+ CreateFeedModel(
+ feedContent = content,
+ feedTypeId = _selectedType.value?.exerciseTypeId,
+ )
+ when (val result = createFeedUseCase(model)) {
+ is BaseResult.Success -> {
+ val createdFeedId = result.data.feedId
+ val images = _selectedImages.value ?: emptyList()
+ if (images.isNotEmpty()) {
+ val imageUriStrings = images.map { it.toString() }
+ when (uploadFeedImagesUseCase(createdFeedId, imageUriStrings)) {
+ is BaseResult.Success -> {
+ _createSuccess.value = createdFeedId
+ }
+ is BaseResult.Error -> {
+ _createSuccess.value = createdFeedId
+ _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error))
+ }
+ }
+ } else {
+ _createSuccess.value = createdFeedId
+ }
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_form_create_error))
+ }
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+ }
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt
new file mode 100644
index 00000000..59159d71
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt
@@ -0,0 +1,14 @@
+package com.project200.undabang.feature.feed.form
+
+import android.net.Uri
+
+data class RegisteredImage(
+ val imageId: Long,
+ val imageUrl: String,
+)
+
+sealed class FeedFormImageItem {
+ data class Existing(val imageId: Long, val imageUrl: String) : FeedFormImageItem()
+
+ data class New(val uri: Uri) : FeedFormImageItem()
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt
new file mode 100644
index 00000000..07d6c347
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt
@@ -0,0 +1,99 @@
+package com.project200.undabang.feature.feed.list
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.project200.domain.model.Feed
+import com.project200.presentation.utils.RelativeTimeUtil
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.ItemFeedBinding
+
+class FeedListAdapter(
+ private val onItemClick: (Feed) -> Unit,
+) : RecyclerView.Adapter() {
+ private val items = mutableListOf()
+ private var currentMemberId: String? = null
+
+ fun submitList(newItems: List) {
+ items.clear()
+ items.addAll(newItems)
+ notifyDataSetChanged()
+ }
+
+ fun setCurrentMemberId(memberId: String) {
+ currentMemberId = memberId
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): FeedViewHolder {
+ val binding =
+ ItemFeedBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ return FeedViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(
+ holder: FeedViewHolder,
+ position: Int,
+ ) {
+ holder.bind(items[position])
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ inner class FeedViewHolder(private val binding: ItemFeedBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(feed: Feed) {
+ with(binding) {
+ nicknameTv.text = feed.nickname
+ timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt)
+
+ val hasType = !feed.feedTypeName.isNullOrBlank()
+ arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE
+ feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE
+ if (hasType) {
+ feedTypeTv.text = feed.feedTypeName
+ }
+
+ Glide.with(root.context)
+ .load(feed.profileUrl)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .circleCrop()
+ .into(profileIv)
+
+ contentTv.text = feed.feedContent
+
+ if (feed.feedPictures.isNotEmpty()) {
+ imagesRv.visibility = View.VISIBLE
+
+ val imageAdapter = ImageRVAdapter(feed.feedPictures)
+ imagesRv.adapter = imageAdapter
+ } else {
+ imagesRv.visibility = View.GONE
+ }
+
+ likeIv.setImageResource(
+ if (feed.feedIsLiked) {
+ R.drawable.ic_like_fill
+ } else {
+ R.drawable.ic_like
+ },
+ )
+
+ likeCountTv.text = feed.feedLikesCount.toString()
+ commentCountTv.text = feed.feedCommentsCount.toString()
+
+ root.setOnClickListener { onItemClick(feed) }
+ }
+ }
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt
new file mode 100644
index 00000000..4b350730
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt
@@ -0,0 +1,170 @@
+package com.project200.undabang.feature.feed.list
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.project200.presentation.base.BindingFragment
+import com.project200.presentation.utils.collectToast
+import com.project200.presentation.view.SelectionBottomSheetDialog
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.FragmentFeedListBinding
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class FeedListFragment : BindingFragment(R.layout.fragment_feed_list) {
+ private val viewModel: FeedListViewModel by viewModels()
+ private lateinit var feedAdapter: FeedListAdapter
+
+ override fun getViewBinding(view: View): FragmentFeedListBinding {
+ return FragmentFeedListBinding.bind(view)
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initAdapter()
+ initToolbar()
+ initView()
+ initObserver()
+ observeRefreshSignal()
+ }
+
+ private fun observeRefreshSignal() {
+ val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle
+ savedStateHandle?.getLiveData(REFRESH_KEY)?.observe(viewLifecycleOwner) { shouldRefresh ->
+ if (shouldRefresh) {
+ viewModel.loadFeeds(isRefresh = true)
+ savedStateHandle.remove(REFRESH_KEY)
+ }
+ }
+ }
+
+ private fun initAdapter() {
+ feedAdapter =
+ FeedListAdapter(
+ onItemClick = { feed ->
+ findNavController().navigate(
+ FeedListFragmentDirections.actionFeedListFragmentToFeedDetailFragment(feed.feedId),
+ )
+ },
+ )
+ }
+
+ private fun initToolbar() {
+ binding.baseToolbar.apply {
+ setTitle(getString(R.string.feed_title))
+ showBackButton(false)
+ setSubButton(R.drawable.ic_feed_add) {
+ findNavController().navigate(
+ FeedListFragmentDirections.actionFeedListFragmentToFeedFormFragment(),
+ )
+ }
+ setSecondarySubButton(R.drawable.ic_category) {
+ showCategoryBottomSheet()
+ }
+ }
+ }
+
+ private fun showCategoryBottomSheet() {
+ viewModel.requestShowCategoryBottomSheet()
+ }
+
+ private fun displayCategoryBottomSheet(items: List) {
+ SelectionBottomSheetDialog(items) { selectedType ->
+ viewModel.selectType(selectedType)
+ }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name)
+ viewModel.onCategoryBottomSheetShown()
+ }
+
+ private fun initView() {
+ binding.swipeRefreshLayout.setOnRefreshListener {
+ viewModel.loadFeeds(isRefresh = true)
+ }
+
+ binding.feedListRv.apply {
+ adapter = feedAdapter
+ layoutManager = LinearLayoutManager(context)
+ addOnScrollListener(
+ object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(
+ recyclerView: RecyclerView,
+ dx: Int,
+ dy: Int,
+ ) {
+ super.onScrolled(recyclerView, dx, dy)
+
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ val totalItemCount = layoutManager.itemCount
+ val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
+
+ if (viewModel.canLoadMore() && totalItemCount <= (lastVisibleItem + 5)) {
+ viewModel.loadFeeds()
+ }
+ }
+ },
+ )
+ }
+ }
+
+ private fun initObserver() {
+ viewModel.feedList.observe(viewLifecycleOwner) { feeds ->
+ feedAdapter.submitList(feeds)
+ }
+
+ viewModel.showEmptyView.observe(viewLifecycleOwner) { showEmpty ->
+ binding.emptyTv.visibility = if (showEmpty) View.VISIBLE else View.GONE
+ }
+
+ viewModel.showCategoryBottomSheet.observe(viewLifecycleOwner) { items ->
+ if (!items.isNullOrEmpty()) {
+ displayCategoryBottomSheet(items)
+ }
+ }
+
+ viewModel.selectedType.observe(viewLifecycleOwner) { type ->
+ binding.baseToolbar.apply {
+ if (type != null) {
+ setTitle(type)
+ setSecondarySubButton(null)
+ showBackButton(true) { viewModel.clearType() }
+ } else {
+ setTitle(getString(R.string.feed_title))
+ setSecondarySubButton(R.drawable.ic_category) {
+ showCategoryBottomSheet()
+ }
+ showBackButton(false)
+ }
+ }
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
+ val isInitialLoading = isLoading && feedAdapter.itemCount == 0
+ if (isInitialLoading) {
+ binding.shimmerLayout.visibility = View.VISIBLE
+ binding.shimmerLayout.startShimmer()
+ binding.swipeRefreshLayout.visibility = View.GONE
+ binding.emptyTv.visibility = View.GONE
+ } else {
+ binding.shimmerLayout.stopShimmer()
+ binding.shimmerLayout.visibility = View.GONE
+ binding.swipeRefreshLayout.visibility = View.VISIBLE
+ binding.swipeRefreshLayout.isRefreshing = false
+ }
+ }
+
+ collectToast(viewModel.toastEvent)
+
+ viewModel.currentMemberId.observe(viewLifecycleOwner) { memberId ->
+ feedAdapter.setCurrentMemberId(memberId)
+ }
+ }
+
+ companion object {
+ const val REFRESH_KEY = "feed_list_refresh"
+ }
+}
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt
new file mode 100644
index 00000000..62d63260
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt
@@ -0,0 +1,204 @@
+package com.project200.undabang.feature.feed.list
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.Feed
+import com.project200.domain.usecase.DeleteFeedUseCase
+import com.project200.domain.usecase.GetFeedsUseCase
+import com.project200.domain.usecase.GetMemberIdUseCase
+import com.project200.domain.usecase.GetPreferredExerciseTypesUseCase
+import com.project200.domain.usecase.GetPreferredExerciseUseCase
+import com.project200.presentation.utils.UiText
+import com.project200.undabang.feature.feed.R
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FeedListViewModel
+ @Inject
+ constructor(
+ private val getFeedsUseCase: GetFeedsUseCase,
+ private val getPreferredExerciseUseCase: GetPreferredExerciseUseCase,
+ private val getPreferredExerciseTypesUseCase: GetPreferredExerciseTypesUseCase,
+ private val getMemberIdUseCase: GetMemberIdUseCase,
+ private val deleteFeedUseCase: DeleteFeedUseCase,
+ ) : ViewModel() {
+ private val _feedList = MutableLiveData>()
+ val feedList: LiveData> get() = _feedList
+
+ private val _selectedType = MutableLiveData(null)
+ val selectedType: LiveData = _selectedType
+
+ private val _isLoading = MutableLiveData(false)
+ val isLoading: LiveData get() = _isLoading
+
+ private val _toastEvent = MutableSharedFlow()
+ val toastEvent: SharedFlow = _toastEvent.asSharedFlow()
+
+ private val _isEmpty = MutableLiveData(false)
+ val isEmpty: LiveData get() = _isEmpty
+
+ private val _exerciseTypeList = MutableLiveData>()
+ val exerciseTypeList: LiveData> = _exerciseTypeList
+
+ private val _currentMemberId = MutableLiveData()
+ val currentMemberId: LiveData get() = _currentMemberId
+
+ private val _showEmptyView = MutableLiveData(false)
+ val showEmptyView: LiveData get() = _showEmptyView
+
+ private val _showCategoryBottomSheet = MutableLiveData?>()
+ val showCategoryBottomSheet: LiveData?> get() = _showCategoryBottomSheet
+
+ private var hasNext: Boolean = true
+ private var lastFeedId: Long? = null
+ private val allFeeds = mutableListOf()
+
+ companion object {
+ private const val DEFAULT_PAGE_SIZE = 10
+ }
+
+ init {
+ loadFeeds()
+ loadExerciseTypes()
+ loadCurrentMemberId()
+ }
+
+ private fun loadCurrentMemberId() {
+ viewModelScope.launch {
+ _currentMemberId.value = getMemberIdUseCase()
+ }
+ }
+
+ fun selectType(type: String?) {
+ _selectedType.value = type
+ updateFilteredList()
+ }
+
+ fun clearType() {
+ _selectedType.value = null
+ updateFilteredList()
+ }
+
+ fun canLoadMore(): Boolean {
+ return _selectedType.value == null && _isLoading.value != true && hasNext
+ }
+
+ fun requestShowCategoryBottomSheet() {
+ val items = _exerciseTypeList.value
+ if (items.isNullOrEmpty()) {
+ loadExerciseTypes()
+ } else {
+ _showCategoryBottomSheet.value = items
+ }
+ }
+
+ fun onCategoryBottomSheetShown() {
+ _showCategoryBottomSheet.value = null
+ }
+
+ private fun updateShowEmptyView() {
+ _showEmptyView.value = _isEmpty.value == true && _isLoading.value != true
+ }
+
+ private fun updateFilteredList() {
+ val type = _selectedType.value
+ if (type == null) {
+ _feedList.value = allFeeds.toList()
+ } else {
+ _feedList.value = allFeeds.filter { it.feedTypeName == type }
+ }
+ }
+
+ fun loadExerciseTypes() {
+ if (!_exerciseTypeList.value.isNullOrEmpty()) return
+ viewModelScope.launch {
+ val preferredResult = getPreferredExerciseUseCase()
+ val preferredNames =
+ if (preferredResult is BaseResult.Success) {
+ preferredResult.data.map { it.name }
+ } else {
+ emptyList()
+ }
+
+ val allTypesResult = getPreferredExerciseTypesUseCase()
+ val allNames =
+ if (allTypesResult is BaseResult.Success) {
+ allTypesResult.data.map { it.name }
+ } else {
+ emptyList()
+ }
+
+ val combinedList =
+ mutableListOf().apply {
+ addAll(preferredNames)
+ addAll(allNames.filterNot { preferredNames.contains(it) })
+ }
+
+ _exerciseTypeList.value = combinedList
+ }
+ }
+
+ fun loadFeeds(isRefresh: Boolean = false) {
+ if (isRefresh) {
+ hasNext = true
+ lastFeedId = null
+ allFeeds.clear()
+ }
+
+ if (!hasNext || (_isLoading.value == true && !isRefresh)) return
+
+ _isLoading.value = true
+
+ viewModelScope.launch {
+ when (val result = getFeedsUseCase(lastFeedId, DEFAULT_PAGE_SIZE)) {
+ is BaseResult.Success -> {
+ val newFeeds = result.data.feeds
+ hasNext = result.data.hasNext
+
+ if (newFeeds.isNotEmpty()) {
+ lastFeedId = newFeeds.last().feedId
+ allFeeds.addAll(newFeeds)
+ }
+ updateFilteredList()
+ _isEmpty.value = allFeeds.isEmpty()
+ updateShowEmptyView()
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.unknown_error))
+ if (allFeeds.isEmpty()) {
+ _isEmpty.value = true
+ _feedList.value = emptyList()
+ updateShowEmptyView()
+ }
+ }
+ }
+ _isLoading.value = false
+ updateShowEmptyView()
+ }
+ }
+
+ fun deleteFeed(feedId: Long) {
+ viewModelScope.launch {
+ when (deleteFeedUseCase(feedId)) {
+ is BaseResult.Success -> {
+ allFeeds.removeAll { it.feedId == feedId }
+ updateFilteredList()
+ _isEmpty.value = allFeeds.isEmpty()
+ updateShowEmptyView()
+ _toastEvent.emit(UiText.StringResource(R.string.feed_deleted))
+ }
+ is BaseResult.Error -> {
+ _toastEvent.emit(UiText.StringResource(R.string.feed_delete_error))
+ }
+ }
+ }
+ }
+ }
diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt
new file mode 100644
index 00000000..99507e97
--- /dev/null
+++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt
@@ -0,0 +1,48 @@
+package com.project200.undabang.feature.feed.list
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import com.project200.domain.model.FeedPicture
+import com.project200.presentation.utils.UiUtils.dpToPx
+import com.project200.undabang.feature.feed.R
+import com.project200.undabang.feature.feed.databinding.ItemFeedImageBinding
+
+class ImageRVAdapter(private val pictures: List) :
+ RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): ImageViewHolder {
+ val binding =
+ ItemFeedImageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ return ImageViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(
+ holder: ImageViewHolder,
+ position: Int,
+ ) {
+ holder.bind(pictures[position])
+ }
+
+ override fun getItemCount(): Int = pictures.size
+
+ inner class ImageViewHolder(private val binding: ItemFeedImageBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(picture: FeedPicture) {
+ Glide.with(binding.feedImageIv.context)
+ .load(picture.feedPictureUrl)
+ .placeholder(R.drawable.ic_feed_image_placeholder)
+ .transform(RoundedCorners(dpToPx(binding.root.context, 12f)))
+ .error(R.drawable.ic_feed_image_placeholder)
+ .into(binding.feedImageIv)
+ }
+ }
+}
diff --git a/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml b/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml
new file mode 100644
index 00000000..1bff3a52
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/feature/feed/src/main/res/drawable/bg_image_corner.xml b/feature/feed/src/main/res/drawable/bg_image_corner.xml
new file mode 100644
index 00000000..98eb7c37
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/bg_image_corner.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/feed/src/main/res/drawable/ic_add_image.xml b/feature/feed/src/main/res/drawable/ic_add_image.xml
new file mode 100644
index 00000000..e5f850fb
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_add_image.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_category.xml b/feature/feed/src/main/res/drawable/ic_category.xml
new file mode 100644
index 00000000..88233247
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_category.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_comment.xml b/feature/feed/src/main/res/drawable/ic_comment.xml
new file mode 100644
index 00000000..de2ef37d
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_comment.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_delete_circle.xml b/feature/feed/src/main/res/drawable/ic_delete_circle.xml
new file mode 100644
index 00000000..07f8b290
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_delete_circle.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_feed_add.xml b/feature/feed/src/main/res/drawable/ic_feed_add.xml
new file mode 100644
index 00000000..87ec4633
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_feed_add.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml b/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml
new file mode 100644
index 00000000..34162a1c
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_like.xml b/feature/feed/src/main/res/drawable/ic_like.xml
new file mode 100644
index 00000000..ca4ce4d0
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_like.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_like_fill.xml b/feature/feed/src/main/res/drawable/ic_like_fill.xml
new file mode 100644
index 00000000..edb72766
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_like_fill.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_more.xml b/feature/feed/src/main/res/drawable/ic_more.xml
new file mode 100644
index 00000000..79c293ce
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_more.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_send.xml b/feature/feed/src/main/res/drawable/ic_send.xml
new file mode 100644
index 00000000..c7efa9f4
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_send.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/feature/feed/src/main/res/drawable/ic_send_unable.xml b/feature/feed/src/main/res/drawable/ic_send_unable.xml
new file mode 100644
index 00000000..1b68b2ff
--- /dev/null
+++ b/feature/feed/src/main/res/drawable/ic_send_unable.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/fragment_feed_detail.xml b/feature/feed/src/main/res/layout/fragment_feed_detail.xml
new file mode 100644
index 00000000..700ad015
--- /dev/null
+++ b/feature/feed/src/main/res/layout/fragment_feed_detail.xml
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/fragment_feed_form.xml b/feature/feed/src/main/res/layout/fragment_feed_form.xml
new file mode 100644
index 00000000..4fd4e8ef
--- /dev/null
+++ b/feature/feed/src/main/res/layout/fragment_feed_form.xml
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/fragment_feed_list.xml b/feature/feed/src/main/res/layout/fragment_feed_list.xml
new file mode 100644
index 00000000..1d9652c1
--- /dev/null
+++ b/feature/feed/src/main/res/layout/fragment_feed_list.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/item_comment.xml b/feature/feed/src/main/res/layout/item_comment.xml
new file mode 100644
index 00000000..7ec01c7a
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_comment.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/item_feed.xml b/feature/feed/src/main/res/layout/item_feed.xml
new file mode 100644
index 00000000..d9c08e24
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_feed.xml
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/item_feed_form_image.xml b/feature/feed/src/main/res/layout/item_feed_form_image.xml
new file mode 100644
index 00000000..045c7b53
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_feed_form_image.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/item_feed_image.xml b/feature/feed/src/main/res/layout/item_feed_image.xml
new file mode 100644
index 00000000..42f74c73
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_feed_image.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/feature/feed/src/main/res/layout/item_feed_skeleton.xml b/feature/feed/src/main/res/layout/item_feed_skeleton.xml
new file mode 100644
index 00000000..f2099edf
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_feed_skeleton.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/item_reply.xml b/feature/feed/src/main/res/layout/item_reply.xml
new file mode 100644
index 00000000..89a31aca
--- /dev/null
+++ b/feature/feed/src/main/res/layout/item_reply.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/layout_comment_input.xml b/feature/feed/src/main/res/layout/layout_comment_input.xml
new file mode 100644
index 00000000..bfd15a29
--- /dev/null
+++ b/feature/feed/src/main/res/layout/layout_comment_input.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml b/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml
new file mode 100644
index 00000000..faf41bc4
--- /dev/null
+++ b/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/navigation/feed_nav_graph.xml b/feature/feed/src/main/res/navigation/feed_nav_graph.xml
new file mode 100644
index 00000000..921464ee
--- /dev/null
+++ b/feature/feed/src/main/res/navigation/feed_nav_graph.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/feed/src/main/res/values/colors.xml b/feature/feed/src/main/res/values/colors.xml
new file mode 100644
index 00000000..2ccc0866
--- /dev/null
+++ b/feature/feed/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #446DF6
+
\ No newline at end of file
diff --git a/feature/feed/src/main/res/values/strings.xml b/feature/feed/src/main/res/values/strings.xml
new file mode 100644
index 00000000..8954c5bf
--- /dev/null
+++ b/feature/feed/src/main/res/values/strings.xml
@@ -0,0 +1,40 @@
+
+
+
+ 피드
+ 아직 작성된 피드가 없어요\n첫 번째 피드를 작성해보세요!
+ 피드를 불러오는 데 실패했습니다.
+ 좋아요 %d
+ 댓글 %d
+ 다방 선택
+ 알 수 없는 오류가 발생했습니다.
+
+
+ 새로운 피드
+ 피드 수정
+ 완료
+ 피드 내용을 입력해주세요.
+ 이미지 추가
+ 내용을 입력해주세요.
+ 피드가 등록되었습니다.
+ 피드 등록에 실패했습니다.
+ 피드는 등록되었으나 이미지 업로드에 실패했습니다.
+ 피드가 수정되었습니다.
+ 피드 수정에 실패했습니다.
+ 프로필을 불러오는데 실패했습니다.
+ @%s 님에게 답글 작성 중
+
+
+ 답글 달기
+ 댓글 추가…
+ 아직 댓글이 없습니다.
+ 데이터를 불러오는데 실패했습니다.
+ 피드가 삭제되었습니다.
+ 피드 삭제에 실패했습니다.
+ 댓글이 작성되었습니다.
+ 댓글이 삭제되었습니다.
+ 댓글을 불러오는데 실패했습니다.
+ 댓글 작성에 실패했습니다.
+ 댓글 삭제에 실패했습니다.
+ 좋아요 처리에 실패했습니다.
+
diff --git a/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt b/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt
new file mode 100644
index 00000000..6c7e5b50
--- /dev/null
+++ b/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt
@@ -0,0 +1,10 @@
+package com.project200.feature.feed
+
+import org.junit.Test
+
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assert(2 + 2 == 4)
+ }
+}
diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt
index 994fca75..9c9cb8cb 100644
--- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt
+++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt
@@ -242,8 +242,8 @@ class MypageFragment : BindingFragment(R.layout.fragment_
Glide.with(binding.mypageProfileIv)
.load(imageRes)
- .placeholder(R.drawable.ic_profile_default)
- .error(R.drawable.ic_profile_default)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
.into(binding.mypageProfileIv)
}
diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt
index d72d645b..ea28826c 100644
--- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt
+++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt
@@ -215,8 +215,8 @@ class ProfileEditFragment :
private fun setupProfileImage(imageUrl: String?) {
Glide.with(binding.profileImgIv)
.load(imageUrl)
- .placeholder(R.drawable.ic_profile_default)
- .error(R.drawable.ic_profile_default)
+ .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default)
+ .error(com.project200.undabang.presentation.R.drawable.ic_profile_default)
.into(binding.profileImgIv)
}
diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt
index 68f5aef3..906000b9 100644
--- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt
+++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt
@@ -30,7 +30,7 @@ class ProfileImageAdapter : ListAdapter Unit)? = null,
) {
binding.backBtn.apply {
+ setImageResource(com.project200.undabang.presentation.R.drawable.ic_arrow_back)
visibility = if (show) View.VISIBLE else View.INVISIBLE
setOnClickListener { onClick?.invoke() }
}
diff --git a/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt b/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt
new file mode 100644
index 00000000..f251c3cf
--- /dev/null
+++ b/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt
@@ -0,0 +1,19 @@
+package com.project200.presentation.utils
+
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+inline fun Fragment.collectFlow(
+ flow: Flow,
+ crossinline action: suspend (T) -> Unit,
+) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ flow.collect { action(it) }
+ }
+ }
+}
diff --git a/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt b/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt
new file mode 100644
index 00000000..c6ef67ec
--- /dev/null
+++ b/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt
@@ -0,0 +1,37 @@
+package com.project200.presentation.utils
+
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.time.temporal.ChronoUnit
+
+object RelativeTimeUtil {
+ fun getRelativeTime(localDateTime: LocalDateTime?): String {
+ if (localDateTime == null) return ""
+
+ val now = LocalDateTime.now()
+ val seconds = ChronoUnit.SECONDS.between(localDateTime, now)
+ val minutes = ChronoUnit.MINUTES.between(localDateTime, now)
+ val hours = ChronoUnit.HOURS.between(localDateTime, now)
+ val days = ChronoUnit.DAYS.between(localDateTime, now)
+
+ return when {
+ seconds < 60 -> "방금 전"
+ minutes < 60 -> "${minutes}분 전"
+ hours < 24 -> "${hours}시간 전"
+ days < 7 -> "${days}일 전"
+ days < 30 -> "${days / 7}주 전"
+ days < 365 -> "${days / 30}개월 전"
+ else -> "${days / 365}년 전"
+ }
+ }
+
+ fun getRelativeTime(isoString: String?): String {
+ if (isoString.isNullOrEmpty()) return ""
+ return try {
+ val parsed = LocalDateTime.parse(isoString, DateTimeFormatter.ISO_DATE_TIME)
+ getRelativeTime(parsed)
+ } catch (e: Exception) {
+ isoString
+ }
+ }
+}
diff --git a/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt b/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt
new file mode 100644
index 00000000..1e148cca
--- /dev/null
+++ b/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt
@@ -0,0 +1,22 @@
+package com.project200.presentation.utils
+
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+fun Fragment.collectToast(
+ toastFlow: Flow,
+ duration: Int = Toast.LENGTH_SHORT,
+) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ toastFlow.collect { uiText ->
+ Toast.makeText(requireContext(), uiText.asString(requireContext()), duration).show()
+ }
+ }
+ }
+}
diff --git a/presentation/src/main/java/com/project200/presentation/utils/UiText.kt b/presentation/src/main/java/com/project200/presentation/utils/UiText.kt
new file mode 100644
index 00000000..591cdec0
--- /dev/null
+++ b/presentation/src/main/java/com/project200/presentation/utils/UiText.kt
@@ -0,0 +1,38 @@
+package com.project200.presentation.utils
+
+import android.content.Context
+import androidx.annotation.StringRes
+
+sealed class UiText {
+ data class DynamicString(val value: String) : UiText()
+
+ class StringResource(
+ @StringRes val resId: Int,
+ vararg val args: Any,
+ ) : UiText() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is StringResource) return false
+ return resId == other.resId && args.contentEquals(other.args)
+ }
+
+ override fun hashCode(): Int {
+ var result = resId
+ result = 31 * result + args.contentHashCode()
+ return result
+ }
+ }
+
+ fun asString(context: Context): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> {
+ if (args.isEmpty()) {
+ context.getString(resId)
+ } else {
+ context.getString(resId, *args)
+ }
+ }
+ }
+ }
+}
diff --git a/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt b/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt
index 0d8d6b95..ab76df99 100644
--- a/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt
+++ b/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt
@@ -9,12 +9,14 @@ import com.project200.undabang.presentation.R
import com.project200.undabang.presentation.databinding.BottomSheetDialogMenuBinding
/** 메뉴 바텀 시트 다이얼로그
- * @param onEditClicked 수정 버튼 클릭 시 호출되는 콜백
+ * @param onEditClicked 수정 버튼 클릭 시 호출되는 콜백 (showEditButton이 true일 때만 사용)
* @param onDeleteClicked 삭제 버튼 클릭 시 호출되는 콜백
+ * @param showEditButton 수정 버튼 표시 여부 (기본값: true)
*/
class MenuBottomSheetDialog(
- val onEditClicked: () -> Unit,
+ val onEditClicked: () -> Unit = {},
val onDeleteClicked: () -> Unit,
+ val showEditButton: Boolean = true,
) : BottomSheetDialogFragment() {
private var _binding: BottomSheetDialogMenuBinding? = null
val binding get() = _binding!!
@@ -39,9 +41,14 @@ class MenuBottomSheetDialog(
) {
super.onViewCreated(view, savedInstanceState)
- binding.editBtn.setOnClickListener {
- onEditClicked()
- dismiss()
+ if (showEditButton) {
+ binding.editBtn.visibility = View.VISIBLE
+ binding.editBtn.setOnClickListener {
+ onEditClicked()
+ dismiss()
+ }
+ } else {
+ binding.editBtn.visibility = View.GONE
}
binding.deleteBtn.setOnClickListener {
diff --git a/presentation/src/main/res/drawable/bg_circle_gray.xml b/presentation/src/main/res/drawable/bg_circle_gray.xml
new file mode 100644
index 00000000..600cffb8
--- /dev/null
+++ b/presentation/src/main/res/drawable/bg_circle_gray.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/feature/profile/src/main/res/drawable/ic_profile_default.xml b/presentation/src/main/res/drawable/ic_profile_default.xml
similarity index 100%
rename from feature/profile/src/main/res/drawable/ic_profile_default.xml
rename to presentation/src/main/res/drawable/ic_profile_default.xml
diff --git a/presentation/src/main/res/layout/view_base_toolbar.xml b/presentation/src/main/res/layout/view_base_toolbar.xml
index 883afd44..11c16acb 100644
--- a/presentation/src/main/res/layout/view_base_toolbar.xml
+++ b/presentation/src/main/res/layout/view_base_toolbar.xml
@@ -1,44 +1,56 @@
-
+ android:src="@drawable/ic_arrow_back"
+ android:visibility="visible"
+ android:layout_marginStart="4dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+ android:visibility="invisible"
+ android:layout_marginEnd="4dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/sub_btn"
+ app:layout_constraintTop_toTopOf="parent" />
+
+
diff --git a/presentation/src/main/res/values/styles.xml b/presentation/src/main/res/values/styles.xml
index 429d3ec3..2e89dc0c 100644
--- a/presentation/src/main/res/values/styles.xml
+++ b/presentation/src/main/res/values/styles.xml
@@ -71,4 +71,10 @@
- @style/MenuTextAppearance
+
+
+
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9fb5ef6d..91b4bc7d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -41,3 +41,4 @@ include(":feature:exercise")
include(":feature:timer")
include(":feature:matching")
include(":feature:chatting")
+include(":feature:feed")