diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49797ff9..40866d57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,7 +113,15 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.10.0") // JUnit 4 지원 + testImplementation("junit:junit:4.13.2") // JUnit 4 testImplementation("org.assertj:assertj-core:3.27.6") + + // 단위 테스트를 위한 의존성 + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + testImplementation("io.mockk:mockk:1.13.8") + testImplementation("androidx.arch.core:core-testing:2.2.0") + androidTestImplementation(libs.androidx.junit) androidTestImplementation("org.assertj:assertj-core:3.27.6") androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt b/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt index dda87f42..538f9c0e 100644 --- a/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt +++ b/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt @@ -46,5 +46,4 @@ interface AuthApiService { @Body request: TokensRequestAgainByGuest ): Response - } \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/api/DiaryApiService.kt b/app/src/main/java/com/egobook/app/data/api/DiaryApiService.kt new file mode 100644 index 00000000..33c16b8a --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/api/DiaryApiService.kt @@ -0,0 +1,54 @@ +package com.egobook.app.data.api + +import com.egobook.app.data.model.ApiResponse +import com.egobook.app.data.model.diary.request.DiaryCreateRequest +import com.egobook.app.data.model.diary.request.DiaryUpdateRequest +import com.egobook.app.data.model.diary.response.DiariesResponse +import com.egobook.app.data.model.diary.response.DiaryCreateResponse +import com.egobook.app.data.model.diary.response.DiaryDeleteResponse +import com.egobook.app.data.model.diary.response.DiaryEntryResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface DiaryApiService { + + //일기 목록 불러오기 + @GET("/diaries") + suspend fun getDiaries( + @Query("date") date: String, + @Query("type") type: String, + @Query("page") page: Int = 1, + @Query("size") size: Int = 10 + ): ApiResponse + + //일기 상세 확인 + @GET("/diaries/{diaryId}") + suspend fun getDiary( + @Path("diaryId") diaryId: Long + ): ApiResponse + + //일기 추가 + @POST("/diaries") + suspend fun addDiary( + @Body request: DiaryCreateRequest + ): ApiResponse + + //일기 삭제 + @DELETE("/diaries/{diaryId}") + suspend fun deleteDiary( + @Path("diaryId") diaryId: Long + ): ApiResponse + + //일기 수정 + @PATCH("/diaries/{diaryId}") + suspend fun updateDiary( + @Path("diaryId") diaryId: Long, + @Body request: DiaryUpdateRequest + ): ApiResponse + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/local/UUIDProvider.kt b/app/src/main/java/com/egobook/app/data/local/UUIDProvider.kt deleted file mode 100644 index 5f751c52..00000000 --- a/app/src/main/java/com/egobook/app/data/local/UUIDProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.egobook.app.data.local - -import java.util.UUID - -object UUIDProvider { - - fun generateUUID(): String { - return UUID.randomUUID().toString() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/ApiResponse.kt b/app/src/main/java/com/egobook/app/data/model/ApiResponse.kt index 8bf7a78a..35df8ce0 100644 --- a/app/src/main/java/com/egobook/app/data/model/ApiResponse.kt +++ b/app/src/main/java/com/egobook/app/data/model/ApiResponse.kt @@ -1,13 +1,35 @@ package com.egobook.app.data.model -data class ApiResponse( +import com.egobook.app.data.model.diary.response.EmptyResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable +data class ApiResponse( + @SerialName("code") val code: String, + @SerialName("message") val message: String, + @SerialName("status") val status: Int, + @SerialName("data") val data: T +) + +@Serializable +data class ApiResponseEmpty( + @SerialName("code") + val code: String, + + @SerialName("message") + val message: String, + + @SerialName("status") + val status: Int, + @SerialName("data") + val data: EmptyResponse = EmptyResponse() ) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/GetDiariesResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/GetDiariesResponse.kt deleted file mode 100644 index adf3514b..00000000 --- a/app/src/main/java/com/egobook/app/data/model/diary/GetDiariesResponse.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.egobook.app.data.model.diary - - -data class GetDiariesResponse ( - val dailyCount: Int, - val diaries: DiarySliceResponse -) - -data class DiarySliceResponse( - val content: List, - val currentSlice: Long, - val size: Long, - val hasNext: Boolean -) - -data class DiaryItemResponse( - val diaryId: Long, - val date: String, - val writtenAt: String, - val type: List, - val emotionLevel: Long?, - val content: String, - val createdAt: String, - val updatedAt: String -) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/GetDiaryResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/GetDiaryResponse.kt deleted file mode 100644 index 6c7dfb57..00000000 --- a/app/src/main/java/com/egobook/app/data/model/diary/GetDiaryResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.egobook.app.data.model.diary - -data class GetDiaryResponse ( - val diaryId: Long, - - val date: String, - - val writtenAt: String, - - val types: List, - - val emotionLevel: Long?, - - val content: String, - - val createdAt: String, - - val updatedAt: String - -) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryCreateRequest.kt b/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryCreateRequest.kt new file mode 100644 index 00000000..1de8afca --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryCreateRequest.kt @@ -0,0 +1,16 @@ +package com.egobook.app.data.model.diary.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiaryCreateRequest( + @SerialName("type") + val type: List, + @SerialName("emotionLevel") + val emotionLevel: Int?, + @SerialName("content") + val content: String, + @SerialName("date") + val date: String //LocalDate의 String형식 이어야 함. +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryUpdateRequest.kt b/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryUpdateRequest.kt new file mode 100644 index 00000000..33375658 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/request/DiaryUpdateRequest.kt @@ -0,0 +1,14 @@ +package com.egobook.app.data.model.diary.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiaryUpdateRequest ( + @SerialName("type") + val type: List, + @SerialName("emotionLevel") + val emotionLevel: Int?, + @SerialName("content") + val content: String +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/response/DiariesResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/response/DiariesResponse.kt new file mode 100644 index 00000000..56b478e3 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/response/DiariesResponse.kt @@ -0,0 +1,28 @@ +package com.egobook.app.data.model.diary.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiariesResponse ( + @SerialName("dailyCount") + val dailyCount: Int, + + @SerialName("diaries") + val diaries: DiarySlice +) + +@Serializable +data class DiarySlice( + @SerialName("content") + val content: List, + + @SerialName("page") + val page: Int, + + @SerialName("size") + val size: Int, + + @SerialName("hasNext") + val hasNext: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryCreateResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryCreateResponse.kt new file mode 100644 index 00000000..9c4b73b0 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryCreateResponse.kt @@ -0,0 +1,22 @@ +package com.egobook.app.data.model.diary.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +//일기 생성 +@Serializable +data class DiaryCreateResponse( + @SerialName("entry") + val entry: DiaryEntryResponse, + @SerialName("rewards") + val rewards: List +) +@Serializable +data class Reward( + @SerialName("rewardType") + val rewardType: String, + @SerialName("amount") + val amount: Int, + @SerialName("message") + val message: String +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryDeleteResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryDeleteResponse.kt new file mode 100644 index 00000000..c4be5b7a --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryDeleteResponse.kt @@ -0,0 +1,10 @@ +package com.egobook.app.data.model.diary.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiaryDeleteResponse ( + @SerialName("deleted") + val deleted: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryEntryResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryEntryResponse.kt new file mode 100644 index 00000000..312e7ca5 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/response/DiaryEntryResponse.kt @@ -0,0 +1,33 @@ +package com.egobook.app.data.model.diary.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +//일기 상세 확인, 일기 수정 이 직접 사용, 일기 생성에서 부분 사용 +@Serializable +data class DiaryEntryResponse ( + @SerialName("diaryId") + val diaryId: Long, + + @SerialName("date") + val date: String, + + @SerialName("writtenAt") + val writtenAt: String, + + @SerialName("type") + val type: List, + + @SerialName("emotionLevel") + val emotionLevel: Int?, + + @SerialName("content") + val content: String, + + @SerialName("createdAt") + val createdAt: String, + + @SerialName("updatedAt") + val updatedAt: String + +) diff --git a/app/src/main/java/com/egobook/app/data/model/diary/response/EmptyResponse.kt b/app/src/main/java/com/egobook/app/data/model/diary/response/EmptyResponse.kt new file mode 100644 index 00000000..cfb34dfd --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/diary/response/EmptyResponse.kt @@ -0,0 +1,6 @@ +package com.egobook.app.data.model.diary.response + +import kotlinx.serialization.Serializable + +@Serializable +class EmptyResponse \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/DiaryRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/DiaryRepositoryImpl.kt deleted file mode 100644 index 32fabb33..00000000 --- a/app/src/main/java/com/egobook/app/data/repository/DiaryRepositoryImpl.kt +++ /dev/null @@ -1,280 +0,0 @@ -package com.egobook.app.data.repository - -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType -import com.egobook.app.domain.repository.DiaryRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import java.time.LocalDateTime -import javax.inject.Inject - -class DiaryRepositoryImpl @Inject constructor() : DiaryRepository { - - //더미데이터 삽입 - private val diariesFlow = MutableStateFlow( - listOf( - // --- 오늘 날짜 (Today) --- - Diary( - id = 1L, - content = "오늘 아침, 상쾌하게 하루를 시작했다. 기분이 좋다.", - types = setOf(DiaryType.EMOTION, DiaryType.THANKS), - createdAt = LocalDateTime.now().withHour(8).withMinute(30), - writtenAt = LocalDateTime.now().withHour(8).withMinute(30), - emotionLevel = 5 // VERY_GOOD - ), - Diary( - id = 2L, - content = "아침 운동을 하니까 몸이 가벼워진 느낌이다.", - types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(9).withMinute(0), - writtenAt = LocalDateTime.now().withHour(9).withMinute(0), - emotionLevel = 4 // GOOD - ), - Diary( - id = 3L, - content = "회의에서 좋은 아이디어를 냈다. 팀원들이 좋아해줘서 기뻤다.", - types = setOf(DiaryType.PRAISE, DiaryType.THANKS), - createdAt = LocalDateTime.now().withHour(10).withMinute(30), - writtenAt = LocalDateTime.now().withHour(10).withMinute(30), - emotionLevel = null - ), - Diary( - id = 4L, - content = "커피를 마시면서 잠깐 쉬는 시간. 행복한 순간이다.", - types = setOf(DiaryType.THANKS), - createdAt = LocalDateTime.now().withHour(11).withMinute(0), - writtenAt = LocalDateTime.now().withHour(11).withMinute(0), - emotionLevel = null - ), - Diary( - id = 5L, - content = "점심 먹고 나니 너무 졸리다. 오후 업무가 걱정된다.", - types = setOf(DiaryType.EMOTION, DiaryType.WORRY), - createdAt = LocalDateTime.now().withHour(13).withMinute(10), - writtenAt = LocalDateTime.now().withHour(13).withMinute(15), - emotionLevel = 2 // BAD - ), - Diary( - id = 6L, - content = "프로젝트 진행이 잘 안 풀린다. 답답한 마음이 든다.", - types = setOf(DiaryType.WORRY, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(14).withMinute(30), - writtenAt = LocalDateTime.now().withHour(14).withMinute(30), - emotionLevel = 2 // BAD - ), - Diary( - id = 7L, - content = "동료가 도와줘서 문제를 해결했다. 정말 고맙다.", - types = setOf(DiaryType.THANKS, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(15).withMinute(20), - writtenAt = LocalDateTime.now().withHour(15).withMinute(20), - emotionLevel = 4 // GOOD - ), - Diary( - id = 8L, - content = "오후 간식으로 먹은 케이크가 정말 맛있었다. 작은 행복!", - types = setOf(DiaryType.THANKS), - createdAt = LocalDateTime.now().withHour(16).withMinute(0), - writtenAt = LocalDateTime.now().withHour(16).withMinute(0), - emotionLevel = null - ), - Diary( - id = 9L, - content = "오늘 하루 일을 다 끝냈다. 뿌듯하다!", - types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(17).withMinute(30), - writtenAt = LocalDateTime.now().withHour(17).withMinute(30), - emotionLevel = 5 // VERY_GOOD - ), - Diary( - id = 10L, - content = "오늘 저녁은 뭘 먹을지 고민이다. 하루 중 가장 큰 고민.", - types = setOf(DiaryType.WORRY), - createdAt = LocalDateTime.now().withHour(18).withMinute(0), - writtenAt = LocalDateTime.now().withHour(18).withMinute(0), - emotionLevel = null - ), - Diary( - id = 11L, - content = "가족과 함께 저녁을 먹었다. 따뜻한 시간이었다.", - types = setOf(DiaryType.THANKS, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(19).withMinute(30), - writtenAt = LocalDateTime.now().withHour(19).withMinute(30), - emotionLevel = 4 // GOOD - ), - Diary( - id = 12L, - content = "TV 보면서 편하게 쉬는 중. 이런 여유가 필요했다.", - types = setOf(DiaryType.THANKS), - createdAt = LocalDateTime.now().withHour(20).withMinute(0), - writtenAt = LocalDateTime.now().withHour(20).withMinute(0), - emotionLevel = null - ), - Diary( - id = 13L, - content = "오늘 하루를 돌아보니 감사한 일이 많았다.", - types = setOf(DiaryType.THANKS, DiaryType.PRAISE), - createdAt = LocalDateTime.now().withHour(21).withMinute(0), - writtenAt = LocalDateTime.now().withHour(21).withMinute(0), - emotionLevel = null - ), - Diary( - id = 14L, - content = "내일은 더 잘할 수 있을 것 같다. 파이팅!", - types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), - createdAt = LocalDateTime.now().withHour(22).withMinute(0), - writtenAt = LocalDateTime.now().withHour(22).withMinute(0), - emotionLevel = 4 // GOOD - ), - Diary( - id = 15L, - content = "하루를 마무리하며 일기를 쓴다. 좋은 습관이다.", - types = setOf(DiaryType.PRAISE), - createdAt = LocalDateTime.now().withHour(23).withMinute(0), - writtenAt = LocalDateTime.now().withHour(23).withMinute(0), - emotionLevel = null - ), - - // --- 어제 날짜 (Yesterday) --- - Diary( - id = 16L, - content = "어제는 정말 힘든 하루였다. 빨리 잊고 싶다.", - types = setOf(DiaryType.EMOTION), - createdAt = LocalDateTime.now().minusDays(1).withHour(23).withMinute(50), - writtenAt = LocalDateTime.now().minusDays(1).withHour(23).withMinute(50), - emotionLevel = 1 // VERY_BAD - ), - Diary( - id = 17L, - content = "친구에게 작은 선물을 받았는데, 정말 고마웠다.", - types = setOf(DiaryType.THANKS), - createdAt = LocalDateTime.now().minusDays(1).withHour(15).withMinute(0), - writtenAt = LocalDateTime.now().minusDays(1).withHour(15).withMinute(0), - emotionLevel = null - ), - Diary( - id = 18L, - content = "어제 내가 해낸 작은 성과에 대해 스스로를 칭찬한다.", - types = setOf(DiaryType.PRAISE), - createdAt = LocalDateTime.now().minusDays(1).withHour(21).withMinute(0), - writtenAt = LocalDateTime.now().minusDays(1).withHour(22).withMinute(30), // 1시간 30분 뒤 수정 - emotionLevel = null - ), - - // --- 2일 전 날짜 --- - Diary( - id = 19L, - content = "이틀 전, 진로에 대해 계속 고민만 하다 하루가 갔다.", - types = setOf(DiaryType.WORRY), - createdAt = LocalDateTime.now().minusDays(2).withHour(22).withMinute(0), - writtenAt = LocalDateTime.now().minusDays(2).withHour(22).withMinute(10), // 10분 후 수정 - emotionLevel = null - ), - - // --- 3일 전 날짜 --- - Diary( - id = 20L, - content = "3일 전, 오늘 나 자신을 조금은 칭찬해주고 싶었다.", - types = setOf(DiaryType.PRAISE, DiaryType.THANKS), - createdAt = LocalDateTime.now().minusDays(3).withHour(21).withMinute(0), - writtenAt = LocalDateTime.now().minusDays(3).withHour(22).withMinute(0), // 1시간 후 수정 - emotionLevel = null - ), - - // --- 4일 전 날짜 --- - Diary( - id = 21L, - content = "프로젝트를 잘 할 수 있을까 너무 걱정됐던 날.", - types = setOf(DiaryType.EMOTION, DiaryType.WORRY), - createdAt = LocalDateTime.now().minusDays(4).withHour(10).withMinute(0), - writtenAt = LocalDateTime.now().minusDays(3).withHour(11).withMinute(0), // 하루 뒤에 수정 - emotionLevel = 1 // VERY_BAD - ), - - // --- 일주일 전 날짜 --- - Diary( - id = 22L, - content = "일주일 전의 나는 무엇을 하고 있었을까? 평범하지만 괜찮은 하루였다.", - types = setOf(DiaryType.EMOTION), - createdAt = LocalDateTime.now().minusWeeks(1).withHour(16).withMinute(0), - writtenAt = LocalDateTime.now().minusWeeks(1).withHour(16).withMinute(0), - emotionLevel = 4 // GOOD - ), - Diary( - id = 23L, - content = "모든 타입이 포함된 종합 일기. 정말 많은 일이 있었다.", - types = setOf(DiaryType.EMOTION, DiaryType.WORRY, DiaryType.PRAISE, DiaryType.THANKS), - createdAt = LocalDateTime.now().minusWeeks(1).withHour(23).withMinute(0), - writtenAt = LocalDateTime.now().minusWeeks(1).withHour(23).withMinute(0), - emotionLevel = 3 // NORMAL - ) - ) - ) - - override fun getDiaries(): Flow> = - diariesFlow.asStateFlow() - - override suspend fun getDiaryById(id: Long): Result = - runCatching { - diariesFlow.value.find { it.id == id } - } - - override suspend fun addDiary( - content: String, - types: Set, - emotionLevel: Int?, // 1~5 사이의 감정 레벨 - createdAt: LocalDateTime // 일기가 귀속될 날짜 - ): Result = - runCatching { - val now = LocalDateTime.now() // 작성(수정) 시각 - - val newDiary = Diary( - id = (diariesFlow.value.maxOfOrNull { it.id } ?: 0L) + 1, - content = content, - types = types, - emotionLevel = emotionLevel, - createdAt = createdAt, // 선택한 날짜 (일기가 귀속될 날짜) - writtenAt = now // 실제 작성 시각 - ) - - diariesFlow.update { current -> - current + newDiary - } - - newDiary - } - - override suspend fun updateDiary( - id: Long, - content: String, - types: Set, - emotionLevel: Int? - ): Result = - runCatching { - val existing = diariesFlow.value.find { it.id == id } - ?: throw IllegalArgumentException("일기를 찾을 수 없습니다.") - - val updatedDiary = existing.copy( - content = content, - types = types, - emotionLevel = emotionLevel, - // createdAt은 기존 값 유지 (copy시 자동) - writtenAt = LocalDateTime.now() // 수정 시각만 갱신 - ) - - diariesFlow.update { current -> - current.map { if (it.id == id) updatedDiary else it } - } - - updatedDiary - } - - override suspend fun deleteDiaryById(id: Long): Result = - runCatching { - diariesFlow.update { current -> - current.filterNot { it.id == id } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt new file mode 100644 index 00000000..56d8900d --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt @@ -0,0 +1,132 @@ +package com.egobook.app.data.repository.diary + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.egobook.app.data.api.DiaryApiService +import com.egobook.app.data.model.diary.request.DiaryCreateRequest +import com.egobook.app.data.repository.diary.paging.DiariesPagingSource +import com.egobook.app.data.util.safeApiCall +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryCreateRequest +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryEntity +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryUpdateRequest +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toRequestParams +import com.egobook.app.domain.repository.diary.DiaryRepository +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber + +@Singleton //dailyCountCache가 계속 살아있게 하기 위해 추가 +class DiaryRepositoryImpl @Inject constructor( + private val apiService: DiaryApiService +) : DiaryRepository { + + // 날짜별 dailyCount 캐시 + private val dailyCountCache = mutableMapOf() + + override fun getDiaries( + filter: DiaryFilter, + size: Int + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = size, + enablePlaceholders = false + ), + pagingSourceFactory = { + DiariesPagingSource( + apiService = apiService, + filter = filter, + onDailyCountReceived = { count -> + // API 응답 시 dailyCount 캐시 업데이트 + Timber.d("[DailyCount] PagingSource 캐시 업데이트: date=${filter.date}, count=$count") + dailyCountCache[filter.date] = count + } + ) + } + ).flow + } + + override suspend fun getDiaryById(diaryId: Long): Result { + return safeApiCall( + apiCall = { + apiService.getDiary(diaryId) + }, + transform = {it.toDiaryEntity()} + ) + } + + override suspend fun addDiary(diary: Diary): Result { + return safeApiCall( + apiCall = { + apiService.addDiary( + diary.toDiaryCreateRequest() + ) + }, + transform = { Unit } + ) + } + + override suspend fun updateDiary( + diaryId: Long, + diary: Diary + ): Result { + return safeApiCall( + apiCall = { + apiService.updateDiary( + diaryId = diaryId, + request = diary.toDiaryUpdateRequest() + ) + }, + transform = { Unit } + ) + } + + override suspend fun deleteDiaryById(diaryId: Long): Result { + return safeApiCall ( + apiCall = { + apiService.deleteDiary(diaryId) + }, + transform = { Unit } + ) + } + + override suspend fun getDailyCount(date: LocalDate): Result { + // 1. 캐시 먼저 확인 + dailyCountCache[date]?.let { + Timber.d("[DailyCount] 캐시 히트: date=$date, count=$it") + return Result.success(it) + } + + Timber.d("[DailyCount] 캐시 미스: date=$date, API 호출 시작") + + // 2. 캐시 miss면 API 호출 + val filter = DiaryFilter(date, null) + val (dateParam, typesParam) = filter.toRequestParams() + + return safeApiCall( + apiCall = { + apiService.getDiaries( + date = dateParam, + type = typesParam, + page = 1, + size = 1 // dailyCount만 필요하므로 최소 크기로 요청 + ) + }, + transform = { response -> + // API 응답 시 캐시 저장 + dailyCountCache[date] = response.dailyCount + Timber.d("[DailyCount] API 응답 캐시 저장: date=$date, count=${response.dailyCount}") + response.dailyCount + } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/diary/FakeDiaryRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/diary/FakeDiaryRepositoryImpl.kt new file mode 100644 index 00000000..2e74639d --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/repository/diary/FakeDiaryRepositoryImpl.kt @@ -0,0 +1,298 @@ +//package com.egobook.app.data.repository.diary +// +//import com.egobook.app.domain.model.diary.entity.Diary +//import com.egobook.app.domain.model.diary.entity.DiaryType +//import com.egobook.app.domain.repository.diary.FakeDiaryRepository +//import kotlinx.coroutines.flow.Flow +//import kotlinx.coroutines.flow.MutableStateFlow +//import kotlinx.coroutines.flow.asStateFlow +//import kotlinx.coroutines.flow.update +//import java.time.LocalDateTime +//import javax.inject.Inject +// +///** +// * 테스트 및 개발용 더미 일기 저장소 +// * +// * 메모리 내에서 더미 데이터를 관리하며, 실제 API 호출 없이 일기 기능을 테스트할 수 있습니다. +// * +// * 실제 백엔드 API 연동 시: +// * 1. 새로운 DiaryRepositoryImpl 클래스를 생성하여 ApiService를 사용하여 구현 +// * 2. RepositoryModule.kt에서 FakeDiaryRepositoryImpl -> DiaryRepositoryImpl로 변경 +// * +// * @see FakeDiaryRepository +// */ +//class FakeDiaryRepositoryImpl @Inject constructor() : FakeDiaryRepository { +// +// // 더미 데이터 삽입 +// private val diariesFlow = MutableStateFlow( +// listOf( +// // --- 오늘 날짜 (Today) --- +// Diary( +// id = 1L, +// content = "오늘 아침, 상쾌하게 하루를 시작했다. 기분이 좋다.", +// types = setOf(DiaryType.EMOTION, DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().withHour(8).withMinute(30), +// writtenAt = LocalDateTime.now().withHour(8).withMinute(30), +// emotionLevel = 5 // VERY_GOOD +// ), +// Diary( +// id = 2L, +// content = "아침 운동을 하니까 몸이 가벼워진 느낌이다.", +// types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(9).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(9).withMinute(0), +// emotionLevel = 4 // GOOD +// ), +// Diary( +// id = 3L, +// content = "회의에서 좋은 아이디어를 냈다. 팀원들이 좋아해줘서 기뻤다.", +// types = setOf(DiaryType.PRAISE, DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().withHour(10).withMinute(30), +// writtenAt = LocalDateTime.now().withHour(10).withMinute(30), +// emotionLevel = null +// ), +// Diary( +// id = 4L, +// content = "커피를 마시면서 잠깐 쉬는 시간. 행복한 순간이다.", +// types = setOf(DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().withHour(11).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(11).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 5L, +// content = "점심 먹고 나니 너무 졸리다. 오후 업무가 걱정된다.", +// types = setOf(DiaryType.EMOTION, DiaryType.CONCERN), +// createdAt = LocalDateTime.now().withHour(13).withMinute(10), +// writtenAt = LocalDateTime.now().withHour(13).withMinute(15), +// emotionLevel = 2 // BAD +// ), +// Diary( +// id = 6L, +// content = "프로젝트 진행이 잘 안 풀린다. 답답한 마음이 든다.", +// types = setOf(DiaryType.CONCERN, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(14).withMinute(30), +// writtenAt = LocalDateTime.now().withHour(14).withMinute(30), +// emotionLevel = 2 // BAD +// ), +// Diary( +// id = 7L, +// content = "동료가 도와줘서 문제를 해결했다. 정말 고맙다.", +// types = setOf(DiaryType.GRATITUDE, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(15).withMinute(20), +// writtenAt = LocalDateTime.now().withHour(15).withMinute(20), +// emotionLevel = 4 // GOOD +// ), +// Diary( +// id = 8L, +// content = "오후 간식으로 먹은 케이크가 정말 맛있었다. 작은 행복!", +// types = setOf(DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().withHour(16).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(16).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 9L, +// content = "오늘 하루 일을 다 끝냈다. 뿌듯하다!", +// types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(17).withMinute(30), +// writtenAt = LocalDateTime.now().withHour(17).withMinute(30), +// emotionLevel = 5 // VERY_GOOD +// ), +// Diary( +// id = 10L, +// content = "오늘 저녁은 뭘 먹을지 고민이다. 하루 중 가장 큰 고민.", +// types = setOf(DiaryType.CONCERN), +// createdAt = LocalDateTime.now().withHour(18).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(18).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 11L, +// content = "가족과 함께 저녁을 먹었다. 따뜻한 시간이었다.", +// types = setOf(DiaryType.GRATITUDE, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(19).withMinute(30), +// writtenAt = LocalDateTime.now().withHour(19).withMinute(30), +// emotionLevel = 4 // GOOD +// ), +// Diary( +// id = 12L, +// content = "TV 보면서 편하게 쉬는 중. 이런 여유가 필요했다.", +// types = setOf(DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().withHour(20).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(20).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 13L, +// content = "오늘 하루를 돌아보니 감사한 일이 많았다.", +// types = setOf(DiaryType.GRATITUDE, DiaryType.PRAISE), +// createdAt = LocalDateTime.now().withHour(21).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(21).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 14L, +// content = "내일은 더 잘할 수 있을 것 같다. 파이팅!", +// types = setOf(DiaryType.PRAISE, DiaryType.EMOTION), +// createdAt = LocalDateTime.now().withHour(22).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(22).withMinute(0), +// emotionLevel = 4 // GOOD +// ), +// Diary( +// id = 15L, +// content = "하루를 마무리하며 일기를 쓴다. 좋은 습관이다.", +// types = setOf(DiaryType.PRAISE), +// createdAt = LocalDateTime.now().withHour(23).withMinute(0), +// writtenAt = LocalDateTime.now().withHour(23).withMinute(0), +// emotionLevel = null +// ), +// +// // --- 어제 날짜 (Yesterday) --- +// Diary( +// id = 16L, +// content = "어제는 정말 힘든 하루였다. 빨리 잊고 싶다.", +// types = setOf(DiaryType.EMOTION), +// createdAt = LocalDateTime.now().minusDays(1).withHour(23).withMinute(50), +// writtenAt = LocalDateTime.now().minusDays(1).withHour(23).withMinute(50), +// emotionLevel = 1 // VERY_BAD +// ), +// Diary( +// id = 17L, +// content = "친구에게 작은 선물을 받았는데, 정말 고마웠다.", +// types = setOf(DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().minusDays(1).withHour(15).withMinute(0), +// writtenAt = LocalDateTime.now().minusDays(1).withHour(15).withMinute(0), +// emotionLevel = null +// ), +// Diary( +// id = 18L, +// content = "어제 내가 해낸 작은 성과에 대해 스스로를 칭찬한다.", +// types = setOf(DiaryType.PRAISE), +// createdAt = LocalDateTime.now().minusDays(1).withHour(21).withMinute(0), +// writtenAt = LocalDateTime.now().minusDays(1).withHour(22) +// .withMinute(30), // 1시간 30분 뒤 수정 +// emotionLevel = null +// ), +// +// // --- 2일 전 날짜 --- +// Diary( +// id = 19L, +// content = "이틀 전, 진로에 대해 계속 고민만 하다 하루가 갔다.", +// types = setOf(DiaryType.CONCERN), +// createdAt = LocalDateTime.now().minusDays(2).withHour(22).withMinute(0), +// writtenAt = LocalDateTime.now().minusDays(2).withHour(22) +// .withMinute(10), // 10분 후 수정 +// emotionLevel = null +// ), +// +// // --- 3일 전 날짜 --- +// Diary( +// id = 20L, +// content = "3일 전, 오늘 나 자신을 조금은 칭찬해주고 싶었다.", +// types = setOf(DiaryType.PRAISE, DiaryType.GRATITUDE), +// createdAt = LocalDateTime.now().minusDays(3).withHour(21).withMinute(0), +// writtenAt = LocalDateTime.now().minusDays(3).withHour(22).withMinute(0), // 1시간 후 수정 +// emotionLevel = null +// ), +// +// // --- 4일 전 날짜 --- +// Diary( +// id = 21L, +// content = "프로젝트를 잘 할 수 있을까 너무 걱정됐던 날.", +// types = setOf(DiaryType.EMOTION, DiaryType.CONCERN), +// createdAt = LocalDateTime.now().minusDays(4).withHour(10).withMinute(0), +// writtenAt = LocalDateTime.now().minusDays(3).withHour(11).withMinute(0), // 하루 뒤에 수정 +// emotionLevel = 1 // VERY_BAD +// ), +// +// // --- 일주일 전 날짜 --- +// Diary( +// id = 22L, +// content = "일주일 전의 나는 무엇을 하고 있었을까? 평범하지만 괜찮은 하루였다.", +// types = setOf(DiaryType.EMOTION), +// createdAt = LocalDateTime.now().minusWeeks(1).withHour(16).withMinute(0), +// writtenAt = LocalDateTime.now().minusWeeks(1).withHour(16).withMinute(0), +// emotionLevel = 4 // GOOD +// ), +// Diary( +// id = 23L, +// content = "모든 타입이 포함된 종합 일기. 정말 많은 일이 있었다.", +// types = setOf( +// DiaryType.EMOTION, +// DiaryType.CONCERN, +// DiaryType.PRAISE, +// DiaryType.GRATITUDE +// ), +// createdAt = LocalDateTime.now().minusWeeks(1).withHour(23).withMinute(0), +// writtenAt = LocalDateTime.now().minusWeeks(1).withHour(23).withMinute(0), +// emotionLevel = 3 // NORMAL +// ) +// ) +// ) +// +// override fun getDiaries(): Flow> = +// diariesFlow.asStateFlow() +// +// override suspend fun getDiaryById(id: Long): Result = +// runCatching { +// diariesFlow.value.find { it.diaryId == id } +// } +// +// override suspend fun addDiary( +// content: String, +// types: Set, +// emotionLevel: Int?, // 1~5 사이의 감정 레벨 +// createdAt: LocalDateTime // 일기가 귀속될 날짜 +// ): Result = +// runCatching { +// val now = LocalDateTime.now() // 작성(수정) 시각 +// +// val newDiary = Diary( +// id = (diariesFlow.value.maxOfOrNull { it.diaryId } ?: 0L) + 1, +// content = content, +// types = types, +// emotionLevel = emotionLevel, +// createdAt = createdAt, // 선택한 날짜 (일기가 귀속될 날짜) +// writtenAt = now // 실제 작성 시각 +// ) +// +// diariesFlow.update { current -> +// current + newDiary +// } +// +// newDiary +// } +// +// override suspend fun updateDiary( +// id: Long, +// content: String, +// types: Set, +// emotionLevel: Int? +// ): Result = +// runCatching { +// val existing = diariesFlow.value.find { it.id == id } +// ?: throw IllegalArgumentException("일기를 찾을 수 없습니다.") +// +// val updatedDiary = existing.copy( +// content = content, +// types = types, +// emotionLevel = emotionLevel, +// // createdAt은 기존 값 유지 (copy시 자동) +// writtenAt = LocalDateTime.now() // 수정 시각만 갱신 +// ) +// +// diariesFlow.update { current -> +// current.map { if (it.id == id) updatedDiary else it } +// } +// +// updatedDiary +// } +// +// override suspend fun deleteDiaryById(id: Long): Result = +// runCatching { +// diariesFlow.update { current -> +// current.filterNot { it.id == id } +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/diary/paging/DiariesPagingSource.kt b/app/src/main/java/com/egobook/app/data/repository/diary/paging/DiariesPagingSource.kt new file mode 100644 index 00000000..f345a537 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/repository/diary/paging/DiariesPagingSource.kt @@ -0,0 +1,56 @@ +package com.egobook.app.data.repository.diary.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.egobook.app.data.api.DiaryApiService +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryEntity +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toRequestParams +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiarySummary +import timber.log.Timber + + +class DiariesPagingSource( + private val apiService: DiaryApiService, + private val filter: DiaryFilter, + private val onDailyCountReceived: (Int) -> Unit = {} +): PagingSource() { + + override fun getRefreshKey(state: PagingState): Int { + return 1 + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 // api가 보내주는 디폴트값이 1이어서 + val size = params.loadSize + + val (dateParam, typesParam) = filter.toRequestParams() + + //응답 받은거 + val response = apiService.getDiaries( + date = dateParam, + type = typesParam, + page = page, + size = size + ) + + // dailyCount 캐시 업데이트 콜백 호출 + val actualContentSize = response.data.diaries.content.size + val serverDailyCount = response.data.dailyCount + + Timber.d("[DailyCount 디버그] date=$dateParam, 서버 dailyCount=$serverDailyCount, 실제 content 개수=$actualContentSize") + + onDailyCountReceived(serverDailyCount) + + val diarySlice = response.data.diaries + + + return LoadResult.Page( + data = diarySlice.content.map { it.toDiaryEntity().toDiarySummary() }, + prevKey = if (page == 1) null else page - 1, + nextKey = if (diarySlice.hasNext) page + 1 else null + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/paging/MyRepliesHistoryPagingSource.kt b/app/src/main/java/com/egobook/app/data/repository/paging/MyRepliesHistoryPagingSource.kt index e57df355..a2932de4 100644 --- a/app/src/main/java/com/egobook/app/data/repository/paging/MyRepliesHistoryPagingSource.kt +++ b/app/src/main/java/com/egobook/app/data/repository/paging/MyRepliesHistoryPagingSource.kt @@ -18,12 +18,12 @@ import kotlinx.coroutines.delay class MyRepliesHistoryPagingSource(private val apiService: QuestionApiService): PagingSource() { override fun getRefreshKey(state: PagingState): Int { - return 0 // 1 + return 1 // 1 } override suspend fun load(params: LoadParams): LoadResult { return try { - val page = params.key ?: 0 // 2 + val page = params.key ?: 1 // 2 val size = params.loadSize val result = apiService.fetchMyRepliesHistory(page = page, size = size).data diff --git a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt new file mode 100644 index 00000000..28d47cef --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt @@ -0,0 +1,82 @@ +package com.egobook.app.data.util + +import com.egobook.app.data.model.ApiResponse +import com.egobook.app.data.model.ApiResponseEmpty + + +/** + * ApiResponse를 Result로 변환하는 확장 함수 + */ +inline fun ApiResponse.toResult( + transform: (T) -> R +): Result { + return if (this.code == "SUCCESS") { + Result.success(transform(this.data)) + } else { + Result.failure(Exception(this.message)) + } +} + +/** + * ApiResponse를 Result로 변환 (변환 없이) + */ +fun ApiResponse.toResult(): Result { + return if (this.code == "SUCCESS") { + Result.success(this.data) + } else { + Result.failure(Exception(this.message)) + } +} + +/** + * API 호출을 안전하게 실행하는 헬퍼 함수 + */ +suspend fun safeApiCall( + apiCall: suspend () -> ApiResponse +): Result { + return try { + apiCall().toResult() + } catch (e: Exception) { + Result.failure(e) + } +} + + +/** + * API 호출을 안전하게 실행하고 변환하는 헬퍼 함수 + */ +suspend inline fun safeApiCall( + crossinline apiCall: suspend () -> ApiResponse, + crossinline transform: (T) -> R +): Result { + return try { + apiCall().toResult(transform) + } catch (e: Exception) { + Result.failure(e) + } +} + +/** + * 의미있는 데이터를 반환하지 않는 API 응답을 처리하는 전용 확장 함수 + */ + +fun ApiResponseEmpty.toResult(): Result { + return if (this.code == "SUCCESS") { + Result.success(Unit) + } else { + Result.failure(Exception(this.message)) + } +} + +/** + * 빈 응답 API 호출을 안전하게 실행하는 헬퍼 함수 + */ +suspend fun safeApiCallEmpty( + apiCall: suspend () -> ApiResponseEmpty +): Result { + return try { + apiCall().toResult() + } catch (e: Exception) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/di/RepositoryModule.kt b/app/src/main/java/com/egobook/app/di/RepositoryModule.kt index 105188b5..b0470a7a 100644 --- a/app/src/main/java/com/egobook/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/egobook/app/di/RepositoryModule.kt @@ -1,13 +1,12 @@ package com.egobook.app.di import com.egobook.app.data.repository.CounselingRepositoryImpl -import com.egobook.app.data.repository.DiaryRepositoryImpl import com.egobook.app.data.repository.FriendsRepositoryImpl import com.egobook.app.data.repository.NotificationRepositoryImpl import com.egobook.app.domain.repository.CounselingRepository import com.egobook.app.data.repository.auth.AuthRepositoryImpl import com.egobook.app.data.repository.QuestionRepositoryImpl -import com.egobook.app.domain.repository.DiaryRepository +import com.egobook.app.data.repository.diary.DiaryRepositoryImpl import com.egobook.app.domain.repository.FriendsRepository import com.egobook.app.domain.repository.NotificationRepository import com.egobook.app.domain.repository.auth.AuthRepository @@ -15,6 +14,7 @@ import com.egobook.app.ui.shop.NetworkStoreRepository import com.egobook.app.ui.shop.StoreRepository import dagger.Binds import com.egobook.app.domain.repository.QuestionRepository +import com.egobook.app.domain.repository.diary.DiaryRepository import com.egobook.app.ui.home.repository.NetworkTendencyLevelService import com.egobook.app.ui.home.repository.NetworkUserRepository import com.egobook.app.ui.home.repository.UserActivityRepository @@ -52,6 +52,7 @@ abstract class RepositoryModule { @Singleton abstract fun bindDiaryRepository(impl: DiaryRepositoryImpl): DiaryRepository + @Binds @Singleton abstract fun bindStoreRepository(impl: NetworkStoreRepository): StoreRepository diff --git a/app/src/main/java/com/egobook/app/di/ServiceModule.kt b/app/src/main/java/com/egobook/app/di/ServiceModule.kt index dbf031db..0e826898 100644 --- a/app/src/main/java/com/egobook/app/di/ServiceModule.kt +++ b/app/src/main/java/com/egobook/app/di/ServiceModule.kt @@ -2,6 +2,7 @@ package com.egobook.app.di import com.egobook.app.data.api.AuthApiService import com.egobook.app.data.api.CounselingApiService +import com.egobook.app.data.api.DiaryApiService import com.egobook.app.data.api.FriendsApiService import com.egobook.app.data.api.NotificationApiService import com.egobook.app.data.api.QuestionApiService @@ -43,4 +44,10 @@ object ServiceModule { @Singleton fun provideAuthService(retrofit: Retrofit): AuthApiService = retrofit.create(AuthApiService::class.java) + + @Provides + @Singleton + fun provideDiaryService(retrofit: Retrofit): DiaryApiService = + retrofit.create(DiaryApiService::class.java) + } \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/di/UseCaseModule.kt b/app/src/main/java/com/egobook/app/di/UseCaseModule.kt index 5149f347..618dc029 100644 --- a/app/src/main/java/com/egobook/app/di/UseCaseModule.kt +++ b/app/src/main/java/com/egobook/app/di/UseCaseModule.kt @@ -1,7 +1,8 @@ package com.egobook.app.di -import com.egobook.app.domain.repository.DiaryRepository +import com.egobook.app.domain.repository.diary.FakeDiaryRepository import com.egobook.app.domain.repository.auth.AuthRepository +import com.egobook.app.domain.repository.diary.DiaryRepository import com.egobook.app.domain.usecase.authusecase.AuthUseCases import com.egobook.app.domain.usecase.authusecase.GoogleAutoLogin import com.egobook.app.domain.usecase.authusecase.GoogleLogin @@ -11,6 +12,7 @@ import com.egobook.app.domain.usecase.authusecase.GuestReLogin import com.egobook.app.domain.usecase.diaryusecase.AddDiary import com.egobook.app.domain.usecase.diaryusecase.DeleteDiary import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases +import com.egobook.app.domain.usecase.diaryusecase.GetDailyCount import com.egobook.app.domain.usecase.diaryusecase.GetDiaries import com.egobook.app.domain.usecase.diaryusecase.GetDiary import com.egobook.app.domain.usecase.diaryusecase.UpdateDiary @@ -34,7 +36,8 @@ object UseCaseModule { getDiary = GetDiary(repository), addDiary = AddDiary(repository), updateDiary = UpdateDiary(repository), - deleteDiary = DeleteDiary(repository) + deleteDiary = DeleteDiary(repository), + getDailyCount = GetDailyCount(repository) ) } diff --git a/app/src/main/java/com/egobook/app/domain/mapper/gitkeep b/app/src/main/java/com/egobook/app/domain/mapper/gitkeep deleted file mode 100644 index 1689899e..00000000 --- a/app/src/main/java/com/egobook/app/domain/mapper/gitkeep +++ /dev/null @@ -1 +0,0 @@ -sss \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/model/Diary.kt b/app/src/main/java/com/egobook/app/domain/model/Diary.kt deleted file mode 100644 index ee78a187..00000000 --- a/app/src/main/java/com/egobook/app/domain/model/Diary.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.egobook.app.domain.model - - -import java.time.LocalDateTime - - -data class Diary( - val id: Long, //일기 id - val content: String, //내용 - val types: Set, //중복 방지 - val createdAt: LocalDateTime, //최초 생성 시각 - val writtenAt: LocalDateTime, //마지막 수정 시각 - val emotionLevel: Int? //감정 레벨 (1: 매우 나쁨, 2: 나쁨, 3: 보통, 4: 좋음, 5: 매우 좋음). null일 수도 있음 -) { - init { - //일기 타입에 감정이 포함되어 있어야만 기분 선택 가능 -> 도메인 규칙으로 정의 - if (DiaryType.EMOTION !in types && emotionLevel != null) { - throw IllegalStateException( - "emotionLevel은 EMOTION 타입이 포함된 경우에만 설정할 수 있습니다." - ) - } - - // emotionLevel이 null이 아닌 경우 1~5 범위 체크 - if (emotionLevel != null && emotionLevel !in 1..5) { - throw IllegalArgumentException( - "emotionLevel은 1~5 사이의 값이어야 합니다. 현재 값: $emotionLevel" - ) - } - } -} - -enum class DiaryType(val value: String, val displayType: String) { - EMOTION("EMOTION", "감정"), - WORRY("WORRY", "고민"), - PRAISE("PRAISE", "칭찬"), - THANKS("THANKS", "감사"); - - - companion object { - fun fromDisplayType(displayType: String): DiaryType { - return entries.find { it.displayType == displayType } - ?: throw IllegalArgumentException("Unknown display type: $displayType") - } - - } -} diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt new file mode 100644 index 00000000..27c38778 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt @@ -0,0 +1,72 @@ +package com.egobook.app.domain.model.diary.entity + + +import java.time.LocalDate +import java.time.LocalDateTime + + +data class DayDiaries( + val dailyCount: Int, + val diaries: DiaryList +) + +/** + * 일기 페이징 사용 용도의 도메인 엔티티 + */ +data class DiaryList( + val content: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) +/** + * 일기 간단 정보 (목록용) + */ +data class DiarySummary( + val diaryId: Long, + val writtenAt: LocalDateTime, + val types: Set, + val emotionLevel: Int?, + val content: String, +) + +/** + * 일기 상세 정보 도메인 엔티티 + */ +data class Diary( + val diaryId: Long, //일기 id + val date: LocalDate, //종속 날짜 + val writtenAt: LocalDateTime, //마지막 수정 시각 + val types: Set, //중복 방지 + val emotionLevel: Int?, //감정 레벨 (1: 매우 나쁨, 2: 나쁨, 3: 보통, 4: 좋음, 5: 매우 좋음). null일 수도 있음 + val content: String, //내용 + val createdAt: LocalDateTime, //최초 생성 시각 +) { + +} + + +enum class DiaryType(val value: String, val displayType: String) { + EMOTION("EMOTION", "감정"), + CONCERN("CONCERN", "고민"), + PRAISE("PRAISE", "칭찬"), + GRATITUDE("GRATITUDE", "감사"); + companion object { + /** + * API value로 일기 타입 찾기 (예: "EMOTION", "CONCERN") + */ + fun from(value: String): DiaryType { + return entries.find { it.value == value } + ?: throw IllegalArgumentException("Unknown diary type: $value") + } + + /** + * 한글 displayType으로 DiaryType 찾기 (예: "감정", "고민") + */ + fun fromDisplayType(displayType: String): DiaryType { + return entries.find { it.displayType == displayType } + ?: throw IllegalArgumentException("Unknown display type: $displayType") + } + + } +} diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryFilter.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryFilter.kt new file mode 100644 index 00000000..da3bf36d --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryFilter.kt @@ -0,0 +1,8 @@ +package com.egobook.app.domain.model.diary.entity + +import java.time.LocalDate + +data class DiaryFilter( + val date: LocalDate, + val types: Set? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt b/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt new file mode 100644 index 00000000..188dff63 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt @@ -0,0 +1,130 @@ +package com.egobook.app.domain.model.diary.mapper + +import com.egobook.app.data.model.diary.request.DiaryCreateRequest +import com.egobook.app.data.model.diary.request.DiaryUpdateRequest +import com.egobook.app.data.model.diary.response.DiariesResponse +import com.egobook.app.data.model.diary.response.DiaryEntryResponse +import com.egobook.app.data.model.diary.response.DiarySlice +import com.egobook.app.domain.model.diary.entity.DayDiaries +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiaryList +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * Data Layer ↔ Domain Layer 변환 Mapper + */ +object DiaryMapper { + + private val KST: ZoneId = ZoneId.of("Asia/Seoul") + + // ========== Response → Domain Entity ========== + + /** + * DiariesResponse → DayDiaries + */ + fun DiariesResponse.toDayDiariesEntity(): DayDiaries { + return DayDiaries( + dailyCount = dailyCount, + diaries = diaries.toDiaryListEntity() + ) + } + + /** + * DiaryEntryResponse → Diary + */ + fun DiaryEntryResponse.toDiaryEntity(): Diary { + return Diary( + diaryId = diaryId, + date = LocalDate.parse(date), + writtenAt = LocalDateTime.parse(writtenAt), + types = type.map { DiaryType.from(it) }.toSet(), + emotionLevel = emotionLevel, + content = content, + createdAt = LocalDateTime.parse(createdAt) + ) + } + + /** + * UTC 시간 문자열을 KST LocalDateTime으로 변환 + * @param utcString ISO 8601 UTC 형식 (예: "2026-02-08T16:03:07.148Z") + * @return KST LocalDateTime + */ + private fun parseUtcToKst(utcString: String): LocalDateTime { + val withZ = if (utcString.endsWith("Z")) utcString else "${utcString}Z" + return Instant.parse(withZ) + .atZone(KST) + .toLocalDateTime() + } + + + /** + * DiarySlice → DiaryList + */ + fun DiarySlice.toDiaryListEntity(): DiaryList { + return DiaryList( + content = content.map { it.toDiaryEntity().toDiarySummary() }, + page = page, + size = size, + hasNext = hasNext + ) + } + + // ========== Domain Entity → Domain Entity ========== + + /** + * Diary → DiarySummary (도메인 엔티티 간 변환) + */ + fun Diary.toDiarySummary(): DiarySummary { + return DiarySummary( + diaryId = diaryId, + writtenAt = writtenAt, + types = types, + emotionLevel = emotionLevel, + content = content + ) + } + + // ========== Domain Entity -> Request ========== + + /** + * DiaryFilter → RequestParams + */ + + fun DiaryFilter.toRequestParams(): Pair { + val dateParam = date.toString() + val typesParam = types?.joinToString(",") { it.name } ?: "" + + return dateParam to typesParam + } + + /** + * Diary → DiaryCreateRequest + */ + + fun Diary.toDiaryCreateRequest(): DiaryCreateRequest { + return DiaryCreateRequest( + type = types.map { it.value }, + emotionLevel = emotionLevel, + content = content, + date = date.toString() + ) + } + + /** + * Diary → DiaryUpdateRequest + */ + fun Diary.toDiaryUpdateRequest(): DiaryUpdateRequest { + return DiaryUpdateRequest( + type = types.map { it.value }, + emotionLevel = emotionLevel, + content = content + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/repository/DiaryRepository.kt b/app/src/main/java/com/egobook/app/domain/repository/DiaryRepository.kt deleted file mode 100644 index d4481eef..00000000 --- a/app/src/main/java/com/egobook/app/domain/repository/DiaryRepository.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.egobook.app.domain.repository - -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType -import kotlinx.coroutines.flow.Flow -import java.time.LocalDateTime - -interface DiaryRepository { - - fun getDiaries(): Flow> - - suspend fun getDiaryById(id: Long): Result - - suspend fun addDiary( - content: String, - types: Set, - emotionLevel: Int?, // 1~5 사이의 감정 레벨 - createdAt: LocalDateTime // 일기가 귀속될 날짜 - ): Result - - suspend fun updateDiary( - id: Long, - content: String, - types: Set, - emotionLevel: Int? - ): Result - - suspend fun deleteDiaryById(id: Long): Result -} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt b/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt new file mode 100644 index 00000000..d5807126 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt @@ -0,0 +1,48 @@ +package com.egobook.app.domain.repository.diary + +import androidx.paging.PagingData +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate +import java.time.LocalDateTime + +interface DiaryRepository { + fun getDiaries( + filter: DiaryFilter, + size: Int = 10 + ): Flow> + + /** + * 일기 상세 조회 + * @param diaryId 일기 ID + */ + suspend fun getDiaryById(diaryId: Long): Result + + /** + * 일기 생성 + */ + suspend fun addDiary(diary: Diary): Result + + /** + * 일기 수정 + */ + suspend fun updateDiary( + diaryId: Long, + diary: Diary + ): Result + + /** + * 일기 삭제 + */ + suspend fun deleteDiaryById(diaryId: Long): Result + + /** + * 데일리 카운트 가져오기 + */ + suspend fun getDailyCount(date: LocalDate): Result + + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/repository/diary/FakeDiaryRepository.kt b/app/src/main/java/com/egobook/app/domain/repository/diary/FakeDiaryRepository.kt new file mode 100644 index 00000000..e44eb477 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/repository/diary/FakeDiaryRepository.kt @@ -0,0 +1,41 @@ +package com.egobook.app.domain.repository.diary + +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryType +import kotlinx.coroutines.flow.Flow +import java.time.LocalDateTime + +/** + * 테스트 및 개발용 일기 저장소 인터페이스 + * + * 백엔드 API 연동 전에 더미 데이터로 개발하기 위한 임시 인터페이스입니다. + * + * 실제 백엔드 API 연동 시: + * - 새로운 DiaryRepository 인터페이스를 생성 + * - DiaryRepositoryImpl에서 실제 API 호출 구현 + * - 모든 UseCase에서 FakeDiaryRepository → DiaryRepository로 변경 + * + * @see FakeDiaryRepositoryImpl + */ +interface FakeDiaryRepository { + + fun getDiaries(): Flow> + + suspend fun getDiaryById(id: Long): Result + + suspend fun addDiary( + content: String, + types: Set, + emotionLevel: Int?, // 1~5 사이의 감정 레벨 + createdAt: LocalDateTime // 일기가 귀속될 날짜 + ): Result + + suspend fun updateDiary( + id: Long, + content: String, + types: Set, + emotionLevel: Int? + ): Result + + suspend fun deleteDiaryById(id: Long): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt b/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt index 1bb30afa..76589403 100644 --- a/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt +++ b/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt @@ -1,20 +1,24 @@ package com.egobook.app.domain.usecase.diaryusecase -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType -import com.egobook.app.domain.repository.DiaryRepository +import androidx.paging.PagingData +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.domain.repository.diary.DiaryRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import java.time.LocalDate import java.time.LocalDateTime import javax.inject.Inject -// 의존성 주입을 쉽게 하기 위한 래퍼 클래스. +// 의존성 주입을 쉽게 하기 위한 래퍼 클래스 data class DiaryUseCases @Inject constructor ( val getDiaries: GetDiaries, val getDiary: GetDiary, val addDiary: AddDiary, val updateDiary: UpdateDiary, - val deleteDiary: DeleteDiary + val deleteDiary: DeleteDiary, + val getDailyCount: GetDailyCount ) // 각 유스케이스들 정의 @@ -22,30 +26,15 @@ data class DiaryUseCases @Inject constructor ( class GetDiaries @Inject constructor( private val repository: DiaryRepository ) { - operator fun invoke( - selectedDate: LocalDateTime = LocalDateTime.now(), - types: Set? = null // null = 전체 탭 - ): Flow> { - return repository.getDiaries() - .map { diaries -> - diaries - .filter { diary -> - val isSameDate = diary.createdAt.year == selectedDate.year && - diary.createdAt.month == selectedDate.month && - diary.createdAt.dayOfMonth == selectedDate.dayOfMonth - val isCorrectType = types == null || diary.types.any { it in types } - - isSameDate && isCorrectType - } - .sortedByDescending { it.writtenAt } - } + operator fun invoke(filter: DiaryFilter): Flow> { + return repository.getDiaries(filter) } } class GetDiary @Inject constructor( private val repository: DiaryRepository ) { - suspend operator fun invoke(id: Long): Result { + suspend operator fun invoke(id: Long): Result { return repository.getDiaryById(id) } } @@ -53,18 +42,8 @@ class GetDiary @Inject constructor( class AddDiary @Inject constructor( private val repository: DiaryRepository ) { - suspend operator fun invoke( - content: String, - types: Set, - emotionLevel: Int?, // 1~5 사이의 감정 레벨 - createdAt: LocalDateTime // 일기가 귀속될 날짜 - ): Result { - return repository.addDiary( - content = content, - types = types, - emotionLevel = emotionLevel, - createdAt = createdAt - ) + suspend operator fun invoke(diary: Diary): Result { + return repository.addDiary(diary) } } @@ -72,17 +51,10 @@ class UpdateDiary @Inject constructor( private val repository: DiaryRepository ) { suspend operator fun invoke( - id: Long, - content: String, - types: Set, - emotionLevel: Int? - ): Result { - return repository.updateDiary( - id = id, - content = content, - types = types, - emotionLevel = emotionLevel - ) + diaryId: Long, + diary: Diary + ): Result { + return repository.updateDiary(diaryId, diary) } } @@ -94,6 +66,10 @@ class DeleteDiary @Inject constructor( } } - - - +class GetDailyCount @Inject constructor( + private val repository: DiaryRepository +) { + suspend operator fun invoke(date: LocalDate): Result { + return repository.getDailyCount(date) + } +} diff --git a/app/src/main/java/com/egobook/app/ui/diary/adapter/DiaryRVAdapter.kt b/app/src/main/java/com/egobook/app/ui/diary/adapter/DiaryRVAdapter.kt index c1cc96a8..5524c7d7 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/adapter/DiaryRVAdapter.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/adapter/DiaryRVAdapter.kt @@ -5,20 +5,20 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.core.view.isVisible +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.egobook.app.R import com.egobook.app.databinding.ItemDiaryBinding -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType -import com.egobook.app.ui.diary.util.toTimeString +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.ui.util.toTimeString class DiaryRVAdapter : - ListAdapter(DiaryDiffCallback()) { + PagingDataAdapter(DiaryDiffCallback()) { interface MyItemClickListener { - fun onItemClick(diary: Diary) + fun onItemClick(diary: DiarySummary) } private var myItemClickListener: MyItemClickListener? = null @@ -37,17 +37,18 @@ class DiaryRVAdapter : override fun onBindViewHolder(holder: ViewHolder, position: Int) { val diary = getItem(position) - holder.bind(diary) - - holder.itemView.setOnClickListener { - myItemClickListener?.onItemClick(diary) + diary?.let { + holder.bind(it) + holder.itemView.setOnClickListener { + myItemClickListener?.onItemClick(diary) + } } } inner class ViewHolder(private val binding: ItemDiaryBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(diary: Diary) { + fun bind(diary: DiarySummary) { binding.tvDiaryContent.text = diary.content binding.tvTime.text = diary.writtenAt.toTimeString() @@ -61,14 +62,14 @@ class DiaryRVAdapter : } // 타입 순서 정의 - val typeOrder = listOf(DiaryType.EMOTION, DiaryType.WORRY, DiaryType.PRAISE, DiaryType.THANKS) + val typeOrder = listOf(DiaryType.EMOTION, DiaryType.CONCERN, DiaryType.PRAISE, DiaryType.GRATITUDE) // 각 TextView 맵핑 val typeToTextView = mapOf( DiaryType.EMOTION to binding.tvEmotion, - DiaryType.WORRY to binding.tvWorry, + DiaryType.CONCERN to binding.tvWorry, DiaryType.PRAISE to binding.tvPraise, - DiaryType.THANKS to binding.tvThanks + DiaryType.GRATITUDE to binding.tvThanks ) // 모든 TextView 숨김 @@ -98,12 +99,12 @@ class DiaryRVAdapter : } } - class DiaryDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Diary, newItem: Diary): Boolean { - return oldItem.id == newItem.id // ID 기준으로 같은 아이템인지 판단 + class DiaryDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DiarySummary, newItem: DiarySummary): Boolean { + return oldItem.diaryId == newItem.diaryId // ID 기준으로 같은 아이템인지 판단 } - override fun areContentsTheSame(oldItem: Diary, newItem: Diary): Boolean { + override fun areContentsTheSame(oldItem: DiarySummary, newItem: DiarySummary): Boolean { return oldItem == newItem // 내용까지 동일한지 판단 } } diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt new file mode 100644 index 00000000..63e7be6a --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt @@ -0,0 +1,129 @@ +package com.egobook.app.ui.diary.mapper + +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryType +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Domain 모델과 UI 레이어 간의 데이터 변환을 담당하는 매퍼 + * 순수하게 데이터 변환만 담당하며, UI 리소스(이미지, 색상 등)는 UI 레이어에서 처리 + */ +object DiaryEntityMapper { + + // ========== Domain Entity -> UI ========== + + /** + * Domain DiaryType Set -> UI displayTypes Set + */ + fun domainToUiDisplayTypes(types: Set): Set { + return types.map { it.displayType }.toSet() + } + + /** + * Domain DiaryType -> UI displayType("감정", "고민", "칭찬", "감사") + */ + fun domainToUiDisplayType(diaryType: DiaryType): String { + return diaryType.displayType + } + + /** + * Domain Diary -> DiaryCheckFragment에 표시될 값..? 필요하면 정의하는 게 좋을 것 같은데.. + */ + + + // ========== UI -> Domain Entity ========== + + /** + * UI displayTypes Set -> Domain DiaryType Set + */ + fun uiDisplayTypesToDomain(displayTypes: Set): Set { + return displayTypes.mapNotNull { displayType -> + try { + DiaryType.fromDisplayType(displayType) + } catch (e: IllegalArgumentException) { + null // 알 수 없는 타입은 무시 + } + }.toSet() + } + + /** + * UI displayType("감정", "고민", "칭찬", "감사") -> Domain DiaryType + */ + fun uiDisplayTypeToDomain(displayType: String): DiaryType { + return DiaryType.fromDisplayType(displayType) + } + + /** + * UI 년원일 -> Domain Entity LocalDate + */ + fun uiYearMonthDateToDomain(year: Int, month: Int, date: Int): LocalDate { + return LocalDate.of(year, month, date) + } + + /** + * UI 상태를 Domain Diary 엔티티로 변환 (새 일기 생성용, 일기 수정에도 사용가능) + * @param selectedTypes UI displayType Set (예: ["감정", "고민"]) + * @param content 일기 내용 + * @param emotionLevel 감정 레벨 (1~5) + * @return 새로 생성할 Diary 엔티티 (diaryId와 createdAt는 임시값) + */ + //dateTime 하나로 date, writtenAt, createdAt을 모두 설정함 -> 문제점 발생. + fun createNewDiary( + selectedTypes: Set, + content: String, + emotionLevel: Int?, + date: LocalDate, //선택된 날짜 + writtenAt: LocalDateTime // 실제 작성 시간 + ): Diary { + // UI displayType을 Domain DiaryType으로 변환 + val diaryTypes = uiDisplayTypesToDomain(selectedTypes) + + // 감정 타입이 선택되지 않았으면 emotionLevel은 null + val finalEmotionLevel = if (selectedTypes.contains("감정")) { + emotionLevel + } else { + null + } + + return Diary( + diaryId = 0L, // 새 일기는 임시 ID (서버가 생성), 임시 삽입. + date = date, + writtenAt = writtenAt, // 서버가 실제 값으로 대체. 임시 삽입 + types = diaryTypes, + emotionLevel = finalEmotionLevel, + content = content, + createdAt = writtenAt // 서버가 실제 값으로 대체. 임시 삽입 + ) + } + + fun createUpdatedDiary( + diaryId: Long, + selectedTypes: Set, + content: String, + emotionLevel: Int?, + writtenAt: LocalDateTime + ): Diary { + // UI displayType을 Domain DiaryType으로 변환 + val diaryTypes = uiDisplayTypesToDomain(selectedTypes) + + // 감정 타입이 선택되지 않았으면 emotionLevel은 null + val finalEmotionLevel = if (selectedTypes.contains("감정")) { + emotionLevel + } else { + null + } + + return Diary( + diaryId = diaryId, + types = diaryTypes, + emotionLevel = finalEmotionLevel, + content = content, + + //어차피 DiaryMapper에서 걸러지는 값들. 그냥 임시 삽입 + createdAt = writtenAt, + date = writtenAt.toLocalDate(), + writtenAt = writtenAt + ) + } +} diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryMapper.kt deleted file mode 100644 index 1e662165..00000000 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryMapper.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.egobook.app.ui.diary.mapper - -import com.egobook.app.domain.model.DiaryType - -/** - * Domain 모델과 UI 레이어 간의 데이터 변환을 담당하는 매퍼 - * 순수하게 데이터 변환만 담당하며, UI 리소스(이미지, 색상 등)는 UI 레이어에서 처리 - */ -object DiaryMapper { - - // ========== DiaryType 변환 ========== - - /** - * UI displayType("감정", "고민", "칭찬", "감사") -> Domain DiaryType - */ - fun uiDisplayTypeToDomain(displayType: String): DiaryType { - return DiaryType.fromDisplayType(displayType) - } - - /** - * Domain DiaryType -> UI displayType("감정", "고민", "칭찬", "감사") - */ - fun domainToUiDisplayType(diaryType: DiaryType): String { - return diaryType.displayType - } - - /** - * UI displayTypes Set -> Domain DiaryType Set - */ - fun uiDisplayTypesToDomain(displayTypes: Set): Set { - return displayTypes.mapNotNull { displayType -> - try { - DiaryType.fromDisplayType(displayType) - } catch (e: IllegalArgumentException) { - null // 알 수 없는 타입은 무시 - } - }.toSet() - } - - /** - * Domain DiaryType Set -> UI displayTypes Set - */ - fun domainToUiDisplayTypes(types: Set): Set { - return types.map { it.displayType }.toSet() - } - - // ========== EmotionLevel 변환 ========== - // emotionLevel이 Int로 변경되어 별도 변환 불필요 - // Domain과 UI 모두 Int (1~5)를 사용 -} diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/gitkeep b/app/src/main/java/com/egobook/app/ui/diary/mapper/gitkeep deleted file mode 100644 index 1689899e..00000000 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/gitkeep +++ /dev/null @@ -1 +0,0 @@ -sss \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryCheckFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryCheckFragment.kt index 65391626..e40534ce 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryCheckFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryCheckFragment.kt @@ -16,13 +16,14 @@ import com.egobook.app.BlurLevel import com.egobook.app.R import com.egobook.app.applyScreenBlur import com.egobook.app.databinding.FragmentDiaryCheckBinding -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType -import com.egobook.app.ui.diary.util.toDateTimeString -import com.egobook.app.ui.diary.util.toDayOfMonthString -import com.egobook.app.ui.diary.util.toMonthString -import com.egobook.app.ui.diary.util.toYearString +import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.ui.util.toDateTimeString +import com.egobook.app.ui.util.toDayOfMonthString +import com.egobook.app.ui.util.toMonthString +import com.egobook.app.ui.util.toYearString import com.egobook.app.ui.diary.viewmodel.DiaryCheckViewModel +import com.egobook.app.util.UiState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -63,13 +64,13 @@ class DiaryCheckFragment : Fragment() { findNavController().popBackStack() } btnModify.setOnClickListener { - // 수정하기 버튼 클릭 시 DiaryWriteFragment로 이동 - val currentDiary = viewModel.diary.value - if (currentDiary != null) { + val diaryState = viewModel.diaryState.value + if(diaryState is UiState.Success) { + val currentDiary = diaryState.data val action = DiaryCheckFragmentDirections .actionDiaryCheckFragmentToDiaryWriteFragment( - selectedDate = currentDiary.createdAt.toString(), - diaryId = currentDiary.id + selectedDate = currentDiary.date.toString(), + diaryId = currentDiary.diaryId ) findNavController().navigate(action) } else { @@ -98,13 +99,28 @@ class DiaryCheckFragment : Fragment() { private fun observeDiary() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.diary.collectLatest { diary -> - if (diary != null) { - // diary 객체가 null이 아닐 때 UI를 업데이트합니다. - updateUi(diary) - } else { - // diary가 null이면 (데이터 로딩 실패 등) 사용자에게 알립니다. - Toast.makeText(requireContext(), "일기를 불러오는데 실패했습니다.", Toast.LENGTH_SHORT).show() + viewModel.diaryState.collectLatest { state -> + when (state) { + is UiState.Idle -> { + binding.progressBar.visibility = View.GONE + binding.ivEmotion.visibility = View.GONE + } + is UiState.Loading -> { + binding.progressBar.visibility = View.VISIBLE + binding.ivEmotion.visibility = View.GONE + } + is UiState.Success -> { + binding.progressBar.visibility = View.GONE + updateUi(state.data) + } + is UiState.Failure -> { + binding.progressBar.visibility = View.GONE + binding.ivEmotion.visibility = View.GONE + val message = state.message ?: "알 수 없는 오류가 발생했습니다." + Toast.makeText(requireContext(), + "일기를 불러오는데 실패했습니다: $message", + Toast.LENGTH_SHORT).show() + } } } } @@ -115,14 +131,15 @@ class DiaryCheckFragment : Fragment() { private fun updateUi(diary: Diary) { binding.tvDiaryContent.text = diary.content binding.tvWrittenTime.text = diary.writtenAt.toDateTimeString() - binding.tvDate.text = "${diary.createdAt.toYearString()}년 ${diary.createdAt.toMonthString()}월 ${diary.createdAt.toDayOfMonthString()}일" + binding.tvDate.text = "${diary.date.toYearString()}년 ${diary.date.toMonthString()}월 ${diary.date.toDayOfMonthString()}일" - // 감정 레벨이 있으면 이미지 표시, 없으면 null + // 감정 레벨이 있으면 이미지 표시, 없으면 숨김 if (diary.emotionLevel != null) { + binding.ivEmotion.visibility = View.VISIBLE val emotionImageRes = getEmotionImageRes(diary.emotionLevel) binding.ivEmotion.setImageResource(emotionImageRes) } else { - binding.ivEmotion.setImageDrawable(null) // 기본 이미지 + binding.ivEmotion.visibility = View.GONE } // 일기 타입 표시 (selector를 통해 선택된 타입만 하이라이트) @@ -135,9 +152,9 @@ class DiaryCheckFragment : Fragment() { private fun setDiaryTypes(types: Set) { binding.apply { cvEmotion.isSelected = DiaryType.EMOTION in types - cvThought.isSelected = DiaryType.WORRY in types + cvThought.isSelected = DiaryType.CONCERN in types cvPraise.isSelected = DiaryType.PRAISE in types - cvGratitude.isSelected = DiaryType.THANKS in types + cvGratitude.isSelected = DiaryType.GRATITUDE in types } } diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt index abec3f2c..5b03458c 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt @@ -1,9 +1,12 @@ package com.egobook.app.ui.diary.view import android.os.Bundle + import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup + import android.widget.Toast + import android.graphics.Color import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle @@ -15,19 +18,15 @@ import com.egobook.app.R import com.egobook.app.applyScreenBlur import com.egobook.app.databinding.FragmentDiaryBinding - import com.egobook.app.domain.model.DiaryType import com.egobook.app.ui.diary.adapter.DiaryVPAdapter - import com.egobook.app.ui.diary.util.toDayOfMonthString - import com.egobook.app.ui.diary.util.toMonthString - import com.egobook.app.ui.diary.util.toYearString import com.egobook.app.ui.diary.viewmodel.DiariesEvent import com.egobook.app.ui.diary.viewmodel.DiariesViewModel + import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.getValue - class DiaryFragment : Fragment() { private var _binding: FragmentDiaryBinding? = null private val binding get() = _binding!! @@ -59,14 +58,30 @@ setupClickListener() observeViewModel() } + + override fun onResume() { + super.onResume() + //다른 프래그먼트에서 돌아왔을 때 데이터 새로고침 + viewModel.onEvent(DiariesEvent.RefreshDiaries) + } private fun setupClickListener() { binding.apply { btnAdd.setOnClickListener { - // 현재 선택된 날짜를 ISO 형식으로 변환하여 전달 - val selectedDate = viewModel.state.value.selectedDate.toString() - val action = DiaryFragmentDirections.actionDiaryFragmentToDiaryWriteFragment(selectedDate) - findNavController().navigate(action) + viewLifecycleOwner.lifecycleScope.launch { + // 캐시 기반 dailyCount 조회 (캐시 없으면 API 호출) + val dailyCount = viewModel.getDailyCountWithCache() + + if (dailyCount >= 48) { + showCustomToast() + return@launch + } + + // 48 미만일 때만 일기 작성 화면으로 이동 + val selectedDate = viewModel.state.value.selectedDate.toString() + val action = DiaryFragmentDirections.actionDiaryFragmentToDiaryWriteFragment(selectedDate) + findNavController().navigate(action) + } } btnCalender.setOnClickListener { findNavController().navigate(R.id.action_diaryFragment_to_calenderFragment) @@ -79,12 +94,24 @@ } btnPrevDate.setOnClickListener { val prevDate = viewModel.state.value.selectedDate.minusDays(1) - viewModel.onEvent(DiariesEvent.ChangeDate(prevDate)) + viewModel.onEvent( + DiariesEvent.ChangeDate( + year = prevDate.year, + month = prevDate.monthValue, + day = prevDate.dayOfMonth + ) + ) binding.vpDiary.setCurrentItem(0, false) // "전체" 탭으로 이동 } btnNextDate.setOnClickListener { val nextDate = viewModel.state.value.selectedDate.plusDays(1) - viewModel.onEvent(DiariesEvent.ChangeDate(nextDate)) + viewModel.onEvent( + DiariesEvent.ChangeDate( + year = nextDate.year, + month = nextDate.monthValue, + day = nextDate.dayOfMonth + ) + ) binding.vpDiary.setCurrentItem(0, false) // "전체" 탭으로 이동 } btnGoToTop.setOnClickListener { @@ -97,10 +124,9 @@ viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collectLatest { state -> - binding.tvYear.text = state.selectedDate.toYearString() - binding.tvMonth.text = state.selectedDate.toMonthString() - binding.tvDate.text = state.selectedDate.toDayOfMonthString() - + binding.tvYear.text = state.yearText + binding.tvMonth.text = "${state.monthText}월" + binding.tvDate.text = state.dayText } } } @@ -119,8 +145,8 @@ // 탭 선택 이벤트 처리 binding.tbType.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - val types = getDiaryTypesByPosition(tab.position) - viewModel.onEvent(DiariesEvent.SwipeTab(types)) + val displayTypes = getDisplayTypesByPosition(tab.position) + viewModel.onEvent(DiariesEvent.SwipeTab(displayTypes)) } override fun onTabUnselected(tab: TabLayout.Tab) {} @@ -153,17 +179,43 @@ }) } - private fun getDiaryTypesByPosition(position: Int): Set? { + private fun getDisplayTypesByPosition(position: Int): Set? { return when(position) { 0 -> null // 전체 - 1 -> setOf(DiaryType.EMOTION) - 2 -> setOf(DiaryType.WORRY) - 3 -> setOf(DiaryType.PRAISE) - 4 -> setOf(DiaryType.THANKS) + 1 -> setOf("감정") + 2 -> setOf("고민") + 3 -> setOf("칭찬") + 4 -> setOf("감사") else -> null } } + private fun showCustomToast() { + val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) + + val customView = layoutInflater.inflate(R.layout.toast_over_write, null) + + val layout = snackBar.view as ViewGroup + layout.setPadding(0, 0, 0, 0) + layout.setBackgroundColor(Color.TRANSPARENT) + + layout.addView(customView, 0) + + // BottomNav에 붙이기 + val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) + snackBar.anchorView = bottomNav + + // translationY 대신 margin으로 띄우기 + val extra = (9 * resources.displayMetrics.density).toInt() + val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin += extra + snackBar.view.layoutParams = params + + snackBar.show() + } + + + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryListFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryListFragment.kt index f01cef3a..cf57af6a 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryListFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryListFragment.kt @@ -5,26 +5,20 @@ import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.activity.result.launch -import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import androidx.paging.LoadState import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.egobook.app.R import com.egobook.app.databinding.FragmentDiaryListBinding -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType +import com.egobook.app.domain.model.diary.entity.DiarySummary import com.egobook.app.ui.diary.adapter.DiaryRVAdapter import com.egobook.app.ui.diary.viewmodel.DiariesViewModel import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class DiaryListFragment : Fragment() { @@ -70,32 +64,32 @@ class DiaryListFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collectLatest { state -> - - diaryRVAdapter.submitList(state.diaries) - - val isEmpty = state.diaries.isEmpty() - - binding.layoutEmpty.visibility = - if (isEmpty) View.VISIBLE else View.GONE - - binding.rvDiary.visibility = - if (isEmpty) View.GONE else View.VISIBLE - + state.diaries.collectLatest { pagingData -> + diaryRVAdapter.submitData(pagingData) + } } } } + + // LoadState를 관찰하여 빈 상태 처리 + viewLifecycleOwner.lifecycleScope.launch { + diaryRVAdapter.loadStateFlow.collectLatest { loadStates -> + val isEmpty = loadStates.refresh is LoadState.NotLoading && diaryRVAdapter.itemCount == 0 + + binding.layoutEmpty.isVisible = isEmpty + binding.rvDiary.isVisible = !isEmpty + } + } } - - private fun initRecyclerView() { diaryRVAdapter.setMyItemClickListener(object : DiaryRVAdapter.MyItemClickListener { - override fun onItemClick(diary: Diary) { + override fun onItemClick(diary: DiarySummary) { // 💡 1. 부모 프래그먼트(DiaryFragment)가 생성한 Directions를 사용합니다. val action = DiaryFragmentDirections.actionDiaryFragmentToDiaryCheckFragment( - diaryId = diary.id + diaryId = diary.diaryId ) // 💡 2. 부모 프래그먼트의 NavController로 action을 실행합니다. parentFragment?.findNavController()?.navigate(action) diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt index 3ff7e29f..ad8703ee 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt @@ -19,10 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.egobook.app.R import com.egobook.app.databinding.FragmentDiaryWriteBinding -import com.egobook.app.ui.diary.util.toDateTimeString -import com.egobook.app.ui.diary.util.toDayOfMonthString -import com.egobook.app.ui.diary.util.toMonthString -import com.egobook.app.ui.diary.util.toYearString +import com.egobook.app.ui.util.toDateTimeString +import com.egobook.app.ui.util.toDayOfMonthString +import com.egobook.app.ui.util.toMonthString +import com.egobook.app.ui.util.toYearString import com.egobook.app.ui.diary.viewmodel.DiaryWriteViewModel import com.google.android.material.imageview.ShapeableImageView import dagger.hilt.android.AndroidEntryPoint @@ -157,9 +157,9 @@ class DiaryWriteFragment : Fragment() { private fun observeSelectedDate() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.selectedDate.collectLatest { date -> + viewModel.selectedDate.collectLatest { selectedDate -> // 날짜를 "2025년 12월 25일" 형식으로 표시 - binding.tvYear.text = "${date.toYearString()}년 ${date.toMonthString()}월 ${date.toDayOfMonthString()}일" + binding.tvYear.text = "${selectedDate.toYearString()}년 ${selectedDate.toMonthString()}월 ${selectedDate.toDayOfMonthString()}일" // 현재 시간을 "2025.12.25 17:32" 형식으로 표시 binding.tvInputTime.text = LocalDateTime.now().toDateTimeString() @@ -183,14 +183,10 @@ class DiaryWriteFragment : Fragment() { // 일기 타입 카드 선택 상태 업데이트 binding.cvEmotion.isSelected = state.selectedTypes.contains("감정") - binding.tvEmotion.isSelected = state.selectedTypes.contains("감정") binding.cvWorry.isSelected = state.selectedTypes.contains("고민") - binding.tvWorry.isSelected = state.selectedTypes.contains("고민") binding.cvPraise.isSelected = state.selectedTypes.contains("칭찬") - binding.tvPraise.isSelected = state.selectedTypes.contains("칭찬") binding.cvThanks.isSelected = state.selectedTypes.contains("감사") - binding.tvThanks.isSelected = state.selectedTypes.contains("감사") - + // "감정" 타입이 선택되었을 때만 레벨 선택 섹션 표시 val isEmotionSelected = state.selectedTypes.contains("감정") val visibility = if (isEmotionSelected) View.VISIBLE else View.GONE diff --git a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModel.kt b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModel.kt index 0bfd4e75..603ce800 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModel.kt @@ -2,17 +2,21 @@ package com.egobook.app.ui.diary.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.egobook.app.domain.model.Diary -import com.egobook.app.domain.model.DiaryType +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.model.diary.entity.DiaryType import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases +import com.egobook.app.ui.diary.mapper.DiaryEntityMapper import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject import kotlinx.coroutines.flow.asStateFlow -import java.time.LocalDateTime +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject @HiltViewModel class DiariesViewModel @Inject constructor( @@ -21,44 +25,89 @@ class DiariesViewModel @Inject constructor( private val _state = MutableStateFlow(DiariesState()) // 뷰모델 내부 갱신용 val state = _state.asStateFlow() // 외부(ui) 읽기 전용 - private var getDiariesJob: Job? = null - init { - getDiaries(LocalDateTime.now(), null) + loadDiaries(LocalDate.now(), null) } fun onEvent(event: DiariesEvent) { when(event) { is DiariesEvent.SwipeTab -> { - getDiaries(state.value.selectedDate, event.types) + val domainTypes = event.displayTypes?.let { + DiaryEntityMapper.uiDisplayTypesToDomain(it) + } + + _state.value = state.value.copy(selectedTabType = domainTypes) + loadDiaries(state.value.selectedDate, domainTypes) } is DiariesEvent.ChangeDate -> { - getDiaries(event.date, null) // 날짜 변경 시 "전체" 탭으로 리셋 + // 도메인 엔티티 형식으로 날짜 변환 + val date = DiaryEntityMapper.uiYearMonthDateToDomain( + event.year, + event.month, + event.day, + ) + _state.value = state.value + .withDate(date) + .copy(selectedTabType = null) + loadDiaries(date, null) // 날짜 변경 시 "전체" 탭으로 리셋 + } + is DiariesEvent.RefreshDiaries -> { + // 현재 선택된 날짜와 탭으로 다시 로드 + loadDiaries(state.value.selectedDate, state.value.selectedTabType) } } } - private fun getDiaries(selectedDate: LocalDateTime, types: Set?) { - getDiariesJob?.cancel() - getDiariesJob = diaryUseCases.getDiaries(selectedDate, types) - .onEach { diaries -> - _state.value = state.value.copy( - diaries = diaries, - selectedTabType = types, - selectedDate = selectedDate - ) - } - .launchIn(viewModelScope) + //상태를 보지 말고 뷰모델 내부 state 기반으로만 동작 + private fun loadDiaries(selectedDate: LocalDate, types: Set?) { + val filter = DiaryFilter(selectedDate, types) + val diariesFlow = diaryUseCases + .getDiaries(filter) + .cachedIn(viewModelScope) + + _state.value = state.value.copy(diaries = diariesFlow) + + // dailyCount도 함께 로드 + //loadDailyCount(selectedDate) + } + + /** + * 캐시 기반 dailyCount 조회 (캐시 없으면 API 호출) + * btnAdd 클릭 시 48 체크용으로 사용 + */ + suspend fun getDailyCountWithCache(): Int { + val currentDate = state.value.selectedDate + return diaryUseCases.getDailyCount(currentDate) + .getOrDefault(state.value.dailyCount) + } + + // 날짜가 바뀌면 UI 표시값까지 자동 변경하는 확장함수 + private fun DiariesState.withDate(date: LocalDate): DiariesState { + return copy( + selectedDate = date, + yearText = date.year.toString(), + monthText = date.monthValue.toString(), + dayText = date.dayOfMonth.toString() + ) } } sealed class DiariesEvent { - data class SwipeTab(val types: Set?) : DiariesEvent() - data class ChangeDate(val date: LocalDateTime) : DiariesEvent() + data class SwipeTab(val displayTypes: Set?) : DiariesEvent() + data class ChangeDate(val year: Int, val month: Int, val day: Int) : DiariesEvent() + data object RefreshDiaries : DiariesEvent() } data class DiariesState( - val diaries: List = emptyList(), + val diaries: Flow> = emptyFlow(), val selectedTabType: Set? = null, - val selectedDate: LocalDateTime = LocalDateTime.now() + + // 내부 로직용 + val selectedDate: LocalDate = LocalDate.now(), + val dailyCount: Int = 0, // 해당 날짜의 일기 개수 + + // UI 표시용 + val yearText: String = selectedDate.year.toString(), + val monthText: String = selectedDate.monthValue.toString(), + val dayText: String = selectedDate.dayOfMonth.toString() ) diff --git a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryCheckViewModel.kt b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryCheckViewModel.kt index 085853c6..0b1e9c00 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryCheckViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryCheckViewModel.kt @@ -3,8 +3,9 @@ package com.egobook.app.ui.diary.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.egobook.app.domain.model.Diary +import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases +import com.egobook.app.util.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -14,11 +15,11 @@ import javax.inject.Inject @HiltViewModel class DiaryCheckViewModel @Inject constructor( private val diaryUseCases: DiaryUseCases, - private val savedStateHandle: SavedStateHandle //다 + private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _diary = MutableStateFlow(null) - val diary = _diary.asStateFlow() + private val _diaryState = MutableStateFlow>(UiState.Loading) + val diaryState = _diaryState.asStateFlow() private val _deleteSuccess = MutableStateFlow(null) val deleteSuccess = _deleteSuccess.asStateFlow() @@ -28,19 +29,21 @@ class DiaryCheckViewModel @Inject constructor( init { if (diaryId != -1L) { getDiary(diaryId) + } else { + _diaryState.value = UiState.Failure("잘못된 일기 ID입니다.") } } private fun getDiary(id: Long) { viewModelScope.launch { + _diaryState.value = UiState.Loading + diaryUseCases.getDiary(id) .onSuccess { fetchedDiary -> - // _diary StateFlow의 값을 직접 업데이트. - _diary.value = fetchedDiary + _diaryState.value = UiState.Success(fetchedDiary) } - .onFailure { - // 실패 시, 크래시를 방지하고 상태를 null로 유지. - _diary.value = null + .onFailure { exception -> + _diaryState.value = UiState.Failure(exception.message) } } } diff --git a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt index ba53f5f1..b068a9b2 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt @@ -5,13 +5,14 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases -import com.egobook.app.ui.diary.mapper.DiaryMapper +import com.egobook.app.ui.diary.mapper.DiaryEntityMapper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.LocalDate import java.time.LocalDateTime import javax.inject.Inject @@ -21,7 +22,7 @@ class DiaryWriteViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _selectedDate = MutableStateFlow(LocalDateTime.now()) + private val _selectedDate = MutableStateFlow(LocalDate.now()) val selectedDate = _selectedDate.asStateFlow() private val _contentState = MutableStateFlow(ContentState()) @@ -43,14 +44,14 @@ class DiaryWriteViewModel @Inject constructor( } fun setupDate() { - // Navigation argument에서 날짜 받기 + // Navigation argument에서 날짜 받기(DiaryFragment에서 넘어옴) val dateString: String? = savedStateHandle.get("selectedDate") if (dateString != null) { try { - _selectedDate.value = LocalDateTime.parse(dateString) + _selectedDate.value = LocalDate.parse(dateString) } catch (e: Exception) { // 파싱 실패 시 현재 날짜 사용 - _selectedDate.value = LocalDateTime.now() + _selectedDate.value = LocalDate.now() } } } @@ -64,11 +65,11 @@ class DiaryWriteViewModel @Inject constructor( .onSuccess { diary -> if (diary != null) { // 기존 일기 데이터로 UI 상태 업데이트 - _selectedDate.value = diary.createdAt - + _selectedDate.value = diary.date + // Domain DiaryType을 UI displayType으로 변환 val displayTypes = diary.types.map { it.displayType }.toSet() - + _contentState.value = _contentState.value.copy( content = diary.content, selectedTypes = displayTypes, @@ -136,41 +137,51 @@ class DiaryWriteViewModel @Inject constructor( } /** - * 일기 저장 처리 (생성 또는 수정) + * 일기 저장 처리 */ private fun saveDiary() { viewModelScope.launch { val state = _contentState.value - - // UI displayType을 Domain DiaryType으로 변환 - val diaryTypes = DiaryMapper.uiDisplayTypesToDomain(state.selectedTypes) - - // 감정 타입이 선택되지 않았으면 emotionLevel은 null - val emotionLevel = if (state.selectedTypes.contains("감정")) { - state.selectedEmotionLevel - } else { - null - } - + // 수정 모드 vs 생성 모드 분기 val result = if (isEditMode) { // 수정 모드: updateDiary 호출 - diaryUseCases.updateDiary( - id = diaryId, + val diaryTypes = DiaryEntityMapper.uiDisplayTypesToDomain(state.selectedTypes) + val emotionLevel = if (state.selectedTypes.contains("감정")) { + state.selectedEmotionLevel + } else { + null + } + val now = LocalDateTime.now() + + val updatedDiary = DiaryEntityMapper.createUpdatedDiary( + diaryId = diaryId, + selectedTypes = state.selectedTypes, content = state.content, - types = diaryTypes, - emotionLevel = emotionLevel + emotionLevel = emotionLevel, + writtenAt = now // 실제 현재 시간으로(임시 삽입) + ) + + diaryUseCases.updateDiary( + diaryId = diaryId, + diary = updatedDiary ) } else { - // 생성 모드: addDiary 호출 - diaryUseCases.addDiary( + // 생성 모드: UI 상태를 Diary 엔티티로 변환 + val now = LocalDateTime.now() + + val newDiary = DiaryEntityMapper.createNewDiary( + selectedTypes = state.selectedTypes, content = state.content, - types = diaryTypes, - emotionLevel = emotionLevel, - createdAt = _selectedDate.value // savedStateHandle로 받은 선택된 날짜 + emotionLevel = state.selectedEmotionLevel, + date = _selectedDate.value, // 선택된 날짜의 일기로 + writtenAt = now // 실제 현재 시간으로(임시 삽입) ) + + // addDiary 호출 + diaryUseCases.addDiary(newDiary) } - + // 저장 결과 전달 result.onSuccess { _saveSuccess.emit(true) // 저장 성공 diff --git a/app/src/main/java/com/egobook/app/ui/diary/util/DateTimeExtensions.kt b/app/src/main/java/com/egobook/app/ui/util/DateTimeExtensions.kt similarity index 89% rename from app/src/main/java/com/egobook/app/ui/diary/util/DateTimeExtensions.kt rename to app/src/main/java/com/egobook/app/ui/util/DateTimeExtensions.kt index 7c98c1ad..0f3489ad 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/util/DateTimeExtensions.kt +++ b/app/src/main/java/com/egobook/app/ui/util/DateTimeExtensions.kt @@ -1,4 +1,4 @@ -package com.egobook.app.ui.diary.util +package com.egobook.app.ui.util import java.time.LocalDate import java.time.LocalDateTime @@ -98,3 +98,12 @@ fun LocalDateTime.toMonthString(): String = fun LocalDateTime.toDayOfMonthString(): String = this.format(DAY_OF_MONTH_FORMATTER) +fun LocalDate.toYearString(): String = + this.format(YEAR_FORMATTER) + +fun LocalDate.toMonthString(): String = + this.format(MONTH_FORMATTER) + +fun LocalDate.toDayOfMonthString(): String = + this.format(DAY_OF_MONTH_FORMATTER) + diff --git a/app/src/main/res/drawable/bg_custom_toast.xml b/app/src/main/res/drawable/bg_custom_toast.xml new file mode 100644 index 00000000..f1e6d2e5 --- /dev/null +++ b/app/src/main/res/drawable/bg_custom_toast.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_xcircle.xml b/app/src/main/res/drawable/ic_xcircle.xml new file mode 100644 index 00000000..8d6eeca8 --- /dev/null +++ b/app/src/main/res/drawable/ic_xcircle.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_diary.xml b/app/src/main/res/layout/fragment_diary.xml index f3270948..d477a32b 100644 --- a/app/src/main/res/layout/fragment_diary.xml +++ b/app/src/main/res/layout/fragment_diary.xml @@ -18,85 +18,89 @@ android:background="@drawable/topbar_background" android:paddingBottom="12dp"> - + - - - - - - - - - - - - + + + + - - - - - - - + android:layout_height="21dp" + android:text="12월" + android:textSize="14sp" + android:fontFamily="@font/arita_semibold" + android:gravity="center"/> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_diary_check.xml b/app/src/main/res/layout/fragment_diary_check.xml index c34e7c6e..4bd3d2dd 100644 --- a/app/src/main/res/layout/fragment_diary_check.xml +++ b/app/src/main/res/layout/fragment_diary_check.xml @@ -48,7 +48,7 @@ android:layout_width="0dp" android:layout_height="30dp" android:layout_weight="1" - android:text="2025년 12월 25일" + android:text="" android:textSize="20sp" android:fontFamily="@font/arita_semibold" android:gravity="center_vertical"/> @@ -58,7 +58,9 @@ android:layout_width="32dp" android:layout_height="32dp" android:layout_marginEnd="16dp" - android:src="@drawable/img_emotion_neutral"/> + android:visibility="gone" + tools:src="@drawable/img_emotion_neutral" + tools:visibility="visible"/> @@ -178,7 +180,7 @@ android:id="@+id/tv_diary_content" android:layout_width="0dp" android:layout_height="wrap_content" - android:text="일기일기일기 일기일기일기 일기일기일기 일기일기일기 일기일기일기 일기일기일기 일기일기일기 일기일기일기 일기일기일기" + android:text="" android:textColor="@color/cos_black" android:textSize="14sp" android:fontFamily="@font/arita_medium" @@ -191,7 +193,7 @@ android:id="@+id/tv_written_time" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="2025.12.25 17:32" + android:text="" android:textColor="@color/grey" android:textSize="12sp" android:fontFamily="@font/arita_semibold" @@ -230,4 +232,16 @@ app:layout_constraintEnd_toEndOf="@id/gd_right" android:layout_marginBottom="16dp"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/toast_over_write.xml b/app/src/main/res/layout/toast_over_write.xml new file mode 100644 index 00000000..d999a158 --- /dev/null +++ b/app/src/main/res/layout/toast_over_write.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dcdb0692..792f913a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,4 +26,7 @@ Not a valid username Password must be >5 characters "Login failed" + + + 하루에 최대 48번 기록 가능해요 diff --git a/app/src/test/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModelTest.kt b/app/src/test/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModelTest.kt new file mode 100644 index 00000000..14831407 --- /dev/null +++ b/app/src/test/java/com/egobook/app/ui/diary/viewmodel/DiariesViewModelTest.kt @@ -0,0 +1,150 @@ +package com.egobook.app.ui.diary.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagingData +import com.egobook.app.domain.model.diary.entity.DiarySummary +import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases +import com.egobook.app.domain.usecase.diaryusecase.GetDailyCount +import com.egobook.app.domain.usecase.diaryusecase.GetDiaries +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +@ExperimentalCoroutinesApi +class DiariesViewModelTest { + + // JUnit 테스트에서 LiveData/StateFlow를 동기적으로 처리하기 위한 규칙 + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: DiariesViewModel + private lateinit var diaryUseCases: DiaryUseCases + private lateinit var getDailyCount: GetDailyCount + private lateinit var getDiaries: GetDiaries + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + // UseCase 모킹 + getDailyCount = mockk() + getDiaries = mockk() + + // GetDiaries 모킹 설정 + every { getDiaries.invoke(any()) } returns flowOf(PagingData.empty()) + + diaryUseCases = DiaryUseCases( + getDiaries = getDiaries, + getDiary = mockk(), + addDiary = mockk(), + updateDiary = mockk(), + deleteDiary = mockk(), + getDailyCount = getDailyCount + ) + + viewModel = DiariesViewModel(diaryUseCases) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `dailyCount가 48 미만이면 해당 값을 반환한다`() = runTest { + // Given + val expectedCount = 47 + coEvery { getDailyCount.invoke(any()) } returns Result.success(expectedCount) + + // When + val result = viewModel.getDailyCountWithCache() + + // Then + assertEquals(expectedCount, result) + assertTrue(result < 48) + } + + @Test + fun `dailyCount가 48이면 48을 반환하고 스낵바 표시 조건이 충족된다`() = runTest { + // Given + val expectedCount = 48 + coEvery { getDailyCount.invoke(any()) } returns Result.success(expectedCount) + + // When + val result = viewModel.getDailyCountWithCache() + + // Then + assertEquals(expectedCount, result) + assertTrue(result >= 48) // 스낵바 표시 조건 + } + + @Test + fun `dailyCount가 48 초과면 해당 값을 반환하고 스낵바 표시 조건이 충족된다`() = runTest { + // Given + val expectedCount = 50 + coEvery { getDailyCount.invoke(any()) } returns Result.success(expectedCount) + + // When + val result = viewModel.getDailyCountWithCache() + + // Then + assertEquals(expectedCount, result) + assertTrue(result >= 48) // 스낵바 표시 조건 + } + + @Test + fun `dailyCount가 0이면 해당 값을 반환한다`() = runTest { + // Given + val expectedCount = 0 + coEvery { getDailyCount.invoke(any()) } returns Result.success(expectedCount) + + // When + val result = viewModel.getDailyCountWithCache() + + // Then + assertEquals(expectedCount, result) + assertTrue(result < 48) + } + + @Test + fun `API 호출 실패 시 현재 state의 dailyCount를 반환한다`() = runTest { + // Given: API 호출 실패, state에는 이미 0이 있음 (init에서 설정) + coEvery { getDailyCount.invoke(any()) } returns Result.failure(Exception("Network error")) + + // When + val result = viewModel.getDailyCountWithCache() + + // Then: getOrDefault로 인해 state의 dailyCount(0) 반환 + assertEquals(0, result) + } + + @Test + fun `날짜 변경 시 새로운 날짜로 dailyCount를 조회한다`() = runTest { + // Given + val expectedCount = 5 + coEvery { getDailyCount.invoke(any()) } returns Result.success(expectedCount) + + // When: 날짜 변경 + viewModel.onEvent(DiariesEvent.ChangeDate(2026, 2, 10)) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val result = viewModel.getDailyCountWithCache() + assertEquals(expectedCount, result) + } +}