From 71e1715305f15bc5ec90a98ecac645616e16604b Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 18 Feb 2026 21:56:27 +0900 Subject: [PATCH] feat: weekdaily add --- .../afternote/domain/model/AfternoteItem.kt | 28 ++ .../data/api/DailyRecordApiService.kt | 1 + .../component/RecordAllSeeReport.kt | 28 +- .../component/RecordShowWeekReport.kt | 133 +++------ .../presentation/component/RecordWeekTotal.kt | 92 ++++-- .../screen/RecordWeekendReportScreen.kt | 34 ++- .../presentation/uimodel/WeeklyStatistics.kt | 8 + .../viewmodel/MindRecordViewModel.kt | 271 +++++++++++------- 8 files changed, 356 insertions(+), 239 deletions(-) create mode 100644 app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/uimodel/WeeklyStatistics.kt diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt index affe9fad..3ce02ed3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteItem.kt @@ -1,6 +1,7 @@ package com.kuit.afternote.feature.afternote.domain.model import com.kuit.afternote.core.domain.model.AfternoteServiceType +import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel /** * 애프터노트 아이템 도메인 모델 @@ -30,3 +31,30 @@ data class AfternoteItem( val processingMethods: List = emptyList(), val galleryProcessingMethods: List = emptyList() ) + +fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { + // 1. 날짜 포맷팅 로직 (안정성을 위해 java.time 사용) + val inputFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy.MM.dd") + val isoFormatter = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd + + val parsedDate = runCatching { + java.time.LocalDate.parse(this.date, inputFormatter) + }.getOrElse { + java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) + } + + val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" + val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" + + return MindRecordUiModel( + // String ID를 Long으로 변환 (UI용 unique key 확보) + id = this.id.hashCode().toLong(), + title = this.serviceName, + formattedDate = formattedDateStr, + draftLabel = "완료", // 애프터노트는 도메인상 완료된 데이터로 간주 + content = this.message, + type = this.type.name, + category = null, // 필요 시 mapping 로직 추가 + originalDate = originalDateStr + ) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt index 0ad99466..ef44af32 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/data/api/DailyRecordApiService.kt @@ -1,5 +1,6 @@ package com.kuit.afternote.feature.dailyrecord.data.api +import com.kuit.afternote.data.remote.ApiResponse import com.kuit.afternote.feature.dailyrecord.data.dto.* import retrofit2.Response import retrofit2.http.* diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordAllSeeReport.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordAllSeeReport.kt index e29215de..a354e3f8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordAllSeeReport.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordAllSeeReport.kt @@ -1,6 +1,8 @@ package com.kuit.afternote.feature.dailyrecord.presentation.component import android.R.attr.top +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel import java.time.LocalDate /** @@ -16,25 +19,28 @@ import java.time.LocalDate @Composable fun RecordAllSeeReport( modifier: Modifier = Modifier, - today: LocalDate + dailySummary: MindRecordViewModel.WeeklySummaryUiState, + afterNoteSummary: MindRecordViewModel.WeeklySummaryUiState ) { Column( - modifier = Modifier - .padding(top = 32.dp) - .padding(horizontal = 20.dp) + modifier = modifier + .padding(top = 32.dp, start = 20.dp, end = 20.dp) ) { - RecordTextComponent( - title = "활동 한 눈에 보기" - ) + RecordTextComponent(title = "활동 한 눈에 보기") Spacer(Modifier.height(16.dp)) + + // 1. 데일리 질문 리포트 RecordShowWeekReport( - standard = "데일리 질문 답변을 3번 작성했어요.", - today = today + titlePrefix = "데일리 질문 답변을", + summary = dailySummary ) + Spacer(Modifier.height(8.dp)) + + // 2. 애프터노트 전체 리포트 RecordShowWeekReport( - standard = "애프터노트에 3번 등록했어요.", - today = today + titlePrefix = "애프터노트에", + summary = afterNoteSummary ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordShowWeekReport.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordShowWeekReport.kt index af4696ec..97f0c072 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordShowWeekReport.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordShowWeekReport.kt @@ -2,6 +2,7 @@ package com.kuit.afternote.feature.dailyrecord.presentation.component import android.os.Build import androidx.annotation.RequiresApi +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -25,13 +26,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R +import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel +import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle import com.kuit.afternote.ui.expand.dropShadow +import com.kuit.afternote.ui.theme.Sansneo import com.kuit.afternote.ui.theme.White import java.time.DayOfWeek import java.time.LocalDate @@ -39,114 +44,58 @@ import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters import java.util.Locale -@RequiresApi(Build.VERSION_CODES.O) @Composable fun RecordShowWeekReport( - modifier: Modifier = Modifier, - standard: String, - today: LocalDate + titlePrefix: String, + summary: MindRecordViewModel.WeeklySummaryUiState ) { var isExpanded by remember { mutableStateOf(false) } - val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - if (!isExpanded) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(height = 56.dp) - .dropShadow( - shape = RoundedCornerShape(16.dp), - color = Color.Black.copy(alpha = 0.05f), - blur = 5.dp, - offsetY = 2.dp, - offsetX = 0.dp, - spread = 0.dp - ).clip(RoundedCornerShape(16.dp)) - .background(color = White) - .clickable { isExpanded = !isExpanded } - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() // 확장 시 애니메이션 추가 + .dropShadow( + shape = RoundedCornerShape(16.dp), + color = Color.Black.copy(alpha = 0.05f), + blur = 5.dp, + offsetY = 2.dp + ) + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .clickable { isExpanded = !isExpanded } + ) { + Column(modifier = Modifier.padding(17.dp)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 17.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = standard, + text = "$titlePrefix ${summary.totalWeeklyCount}번 작성했어요.", fontSize = 16.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + fontFamily = Sansneo ) - Box( + Image( + painter = painterResource(R.drawable.ic_under_direct_foreground), + contentDescription = null, modifier = Modifier - ) { - Image( - painter = painterResource(R.drawable.ic_under_direct_foreground), - contentDescription = null, - modifier = Modifier - .size(30.dp) - ) - } + .size(30.dp) + .rotate(if (isExpanded) 180f else 0f) // 화살표 회전 + ) } - } - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .height(height = 144.dp) - .dropShadow( - shape = RoundedCornerShape(16.dp), - color = Color.Black.copy(alpha = 0.05f), - blur = 5.dp, - offsetY = 2.dp, - offsetX = 0.dp, - spread = 0.dp - ).clip(RoundedCornerShape(16.dp)) - .background(color = White) - .clickable { isExpanded = !isExpanded } - ) { - Column( - modifier = Modifier - .padding(all = 17.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = standard, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - Box( - modifier = Modifier - ) { - Image( - painter = painterResource(R.drawable.ic_under_direct_foreground), - contentDescription = null, - modifier = Modifier - .size(30.dp) - ) - } - } + + if (isExpanded) { Spacer(Modifier.height(16.dp)) - val formatter = DateTimeFormatter.ofPattern("d", Locale.KOREA) // 일(day)만 - val allItems = (0..6).map { offset -> - val date = startOfWeek.plusDays(offset.toLong()) - val dayLabel = date.format(formatter) // "10", "11", ... - listOf("월", "화", "수", "목", "금", "토", "일")[offset] to dayLabel - } - Row( - modifier = Modifier - .fillMaxWidth() - ) { - allItems.forEach { (today, day) -> + Row(modifier = Modifier.fillMaxWidth()) { + summary.calendarDays.forEach { dayState -> SubWeekCheck( - modifier = Modifier.weight(1f), // 각 아이템이 동일한 너비 차지 - today = today, - day = day + modifier = Modifier.weight(1f), + dayLabel = dayState.dayLabel, + dateLabel = dayState.date.toString(), + isRecorded = dayState.style == CalendarDayStyle.FILLED, + isToday = dayState.style == CalendarDayStyle.TODAY ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordWeekTotal.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordWeekTotal.kt index 9c0332c6..9cc93ac3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordWeekTotal.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordWeekTotal.kt @@ -1,6 +1,7 @@ package com.kuit.afternote.feature.dailyrecord.presentation.component import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,7 +20,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel +import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle import com.kuit.afternote.ui.expand.dropShadow +import com.kuit.afternote.ui.theme.B3 import com.kuit.afternote.ui.theme.Sansneo import java.time.DayOfWeek import java.time.LocalDate @@ -30,60 +35,101 @@ import java.util.Locale * 주간리포트 컴포넌트 * 주에 몇번 기록했는 지를 보여줌 */ -@RequiresApi(Build.VERSION_CODES.O) @Composable fun RecordWeekTotal( modifier: Modifier = Modifier, - today: LocalDate, + // today는 상징적 의미 외에 UI 로직에서는 ViewModel의 calendarDays가 우선입니다. + weeklySummaryUiState: MindRecordViewModel.WeeklySummaryUiState ) { - val month = today.monthValue - val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - + Log.d("state", weeklySummaryUiState.calendarDays.toString()) Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .padding(top = 32.dp) .padding(horizontal = 20.dp) ) { Text( - text = "이번주 박서연님은 3번의 마음을 기록하셨네요.", + // 총 횟수를 보여주는 것이 사용자 경험(UX) 측면에서 더 명확합니다. + text = "이번 주 박서연님은 ${weeklySummaryUiState.totalWeeklyCount}번의 마음을 기록하셨네요.", fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = Sansneo, + color = Color(0xFF222222) ) + Box( modifier = Modifier .fillMaxWidth() + .padding(top = 12.dp) .height(90.dp) .dropShadow( shape = RoundedCornerShape(16.dp), color = Color.Black.copy(alpha = 0.05f), blur = 5.dp, - offsetY = 2.dp, - offsetX = 0.dp, - spread = 0.dp - ).clip(RoundedCornerShape(16.dp)) + offsetY = 2.dp + ) + .clip(RoundedCornerShape(16.dp)) .background(Color.White) ) { - val formatter = DateTimeFormatter.ofPattern("d", Locale.KOREA) // 일(day)만 - val allItems = (0..6).map { offset -> - val date = startOfWeek.plusDays(offset.toLong()) - val dayLabel = date.format(formatter) // "10", "11", ... - listOf("월", "화", "수", "목", "금", "토", "일")[offset] to dayLabel - } Row( modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp) + .padding(vertical = 16.dp) ) { - allItems.forEach { (today, day) -> + // ViewModel에서 계산된 7일간의 데이터를 기반으로 UI 렌더링 + weeklySummaryUiState.calendarDays.forEach { dayState -> SubWeekCheck( - modifier = Modifier.weight(1f), // 각 아이템이 동일한 너비 차지 - today = today, - day = day + modifier = Modifier.weight(1f), + dayLabel = dayState.dayLabel, // "월", "화" 등 + dateLabel = dayState.date.toString(), // "10", "11" 등 + // FILLED 상태일 때 하늘색 동그라미를 활성화합니다. + isRecorded = dayState.style == CalendarDayStyle.FILLED, + isToday = dayState.style == CalendarDayStyle.TODAY ) } } } } } + +@Composable +fun SubWeekCheck( + modifier: Modifier = Modifier, + dayLabel: String, + dateLabel: String, + isRecorded: Boolean, + isToday: Boolean +) { + Column( + modifier = modifier, + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + Text( + text = dayLabel, + fontSize = 12.sp, + color = if (isToday) Color(0xFF222222) else Color(0xFFAAAAAA), + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal + ) + + Box( + modifier = Modifier + .padding(top = 8.dp) + .size(32.dp) // 동그라미 크기 고정 + .clip(androidx.compose.foundation.shape.CircleShape) + // 기록됨(isRecorded) 상태일 때 하늘색 배경 적용 + .background( + when { + isRecorded -> B3// 밝은 하늘색 + else -> Color.Transparent + } + ), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Text( + text = dateLabel, + fontSize = 14.sp, + fontWeight = if (isRecorded || isToday) FontWeight.Bold else FontWeight.Medium, + color = if (isRecorded) Color(0xFF007AFF) else Color(0xFF444444) // 기록 시 텍스트는 진한 파랑 + ) + } + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt index 6acdeae8..21d66e07 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordWeekendReportScreen.kt @@ -1,6 +1,7 @@ package com.kuit.afternote.feature.dailyrecord.presentation.screen import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -15,6 +16,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,15 +26,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar +import com.kuit.afternote.feature.afternote.presentation.screen.AfternoteListViewModel import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordAllSeeReport import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordCurrentWeek import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordWeekTotal import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordWeekendReview import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordweekendMindKeyword +import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel import com.kuit.afternote.ui.theme.Gray1 import java.time.DayOfWeek import java.time.LocalDate @@ -45,8 +52,18 @@ import java.util.Locale fun RecordWeekendReportScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, - onBottomNavTabSelected: (BottomNavItem) -> Unit = {} + onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, + mindRecordViewModel: MindRecordViewModel = hiltViewModel() ) { + + val weekState = mindRecordViewModel.totalSummary.collectAsStateWithLifecycle() + val dailyState = mindRecordViewModel.dailyQuestionSummary.collectAsStateWithLifecycle() + val afternoteState = mindRecordViewModel.afternoteSummary.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + mindRecordViewModel.loadRecordsForDiaryList() + } + var selectedBottomNavItem by remember { mutableStateOf(BottomNavItem.RECORD) } val today = LocalDate.now() @@ -121,12 +138,13 @@ fun RecordWeekendReportScreen( } item { RecordWeekTotal( - today = today + weeklySummaryUiState = weekState.value ) } item { RecordAllSeeReport( - today = today + dailySummary = dailyState.value, + afterNoteSummary = afternoteState.value ) } item { @@ -138,3 +156,13 @@ fun RecordWeekendReportScreen( } } } + +@RequiresApi(Build.VERSION_CODES.O) +@Preview +@Composable +private fun RecordWeekendReportScreenPreview(){ + RecordWeekendReportScreen( + onBackClick = {}, + onBottomNavTabSelected = {} + ) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/uimodel/WeeklyStatistics.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/uimodel/WeeklyStatistics.kt new file mode 100644 index 00000000..08518aa6 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/uimodel/WeeklyStatistics.kt @@ -0,0 +1,8 @@ +package com.kuit.afternote.feature.dailyrecord.presentation.uimodel + +import java.time.LocalDate + +data class WeeklyStatistics( + val totalCount: Int = 0, + val dayCounts: Map = emptyMap() // 날짜별 작성 건수 +) diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt index d0603ed1..5b4e1b5b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt @@ -1,27 +1,18 @@ package com.kuit.afternote.feature.dailyrecord.presentation.viewmodel - import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem +import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternotesUseCase import com.kuit.afternote.feature.dailyrecord.data.dto.PostMindRecordRequest -import com.kuit.afternote.feature.dailyrecord.domain.usecase.CreateMindRecordUseCase -import com.kuit.afternote.feature.dailyrecord.domain.usecase.DeleteMindRecordUseCase -import com.kuit.afternote.feature.dailyrecord.domain.usecase.EditMindRecordUseCase -import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetDailyQuestionUseCase -import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordUseCase -import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordsUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.* import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiState import com.kuit.afternote.feature.home.presentation.component.CalendarDay import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import retrofit2.HttpException import java.time.DayOfWeek @@ -62,53 +53,126 @@ class MindRecordViewModel @Inject constructor( private val getDailyQuestionUseCase: GetDailyQuestionUseCase, private val deleteMindRecordUseCase: DeleteMindRecordUseCase, private val getMindRecordUseCase: GetMindRecordUseCase, - private val editMindRecordUseCase: EditMindRecordUseCase + private val editMindRecordUseCase: EditMindRecordUseCase, + private val getAfternotesUseCase: GetAfternotesUseCase ) : ViewModel() { + // --- State 정의 --- private val _records = MutableStateFlow>(emptyList()) val records: StateFlow> = _records + // [추가] 리포트용 독립적 데이터 소스 (UI 데이터 스트림 분리) + private val _dailyQuestionRecords = MutableStateFlow>(emptyList()) + private val _totalRecords = MutableStateFlow>(emptyList()) + private val _afternoteRecords = MutableStateFlow>(emptyList()) + private val _markedDates = MutableStateFlow>(emptySet()) val markedDates: StateFlow> = _markedDates + private val weekDates: List = run { val now = LocalDate.now() val monday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) (0..6).map { monday.plusDays(it.toLong()) } } + // --- 리포트용 UI State --- + data class WeeklySummaryUiState( + val calendarDays: List = emptyList(), + val totalWeeklyCount: Int = 0, + val dayCounts: Map = emptyMap() + ) + + // [추가] 데일리 질문 주간 통계 스트림 + val dailyQuestionSummary: StateFlow = _dailyQuestionRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + + val afternoteSummary: StateFlow = _afternoteRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + // [추가] 전체 기록 주간 통계 스트림 + val totalSummary: StateFlow = _totalRecords + .map { calculateWeeklySummary(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) + + // 기존 캘린더 데이 (메인 화면용 유지) val calendarDays: StateFlow> = _records .map { recordList -> - // 성능 최적화: 기록된 날짜들을 Set으로 변환하여 검색 속도 O(1) 확보 - // MindRecordUiModel에 원본 날짜(yyyy-MM-dd)가 포함되어 있다고 가정합니다. val recordedDates = recordList.map { it.originalDate }.toSet() val today = LocalDate.now() - weekDates.map { date -> - val dateString = date.toString() // "yyyy-MM-dd" format - + val dateString = date.toString() val style = when { date.isEqual(today) -> CalendarDayStyle.TODAY recordedDates.contains(dateString) -> CalendarDayStyle.FILLED - else -> CalendarDayStyle.OUTLINED // 기본값 + else -> CalendarDayStyle.OUTLINED } - CalendarDay( dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), date = date.dayOfMonth, style = style ) } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // --- Helper Functions --- + + private fun loadAfternoteRecords() { + viewModelScope.launch { + getAfternotesUseCase(category = null, page = 0, size = 50).fold( + onSuccess = { pagedData -> + // 매핑 함수 사용 + _afternoteRecords.value = pagedData.items.map { it.toMindRecordUiModel() } + }, + onFailure = { Log.e(TAG, "Afternote 로드 실패", it) } + ) + } + } + // [추가] 주간 통계 계산 로직 (Pure Function) + private fun calculateWeeklySummary(recordList: List): WeeklySummaryUiState { + val today = LocalDate.now() + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val sunday = monday.plusDays(6) + + val weekRange = (0..6).map { monday.plusDays(it.toLong()) } + + // 이번 주 범위 내의 기록만 필터링 + val recordsInThisWeek = recordList.filter { + val d = LocalDate.parse(it.originalDate) + !d.isBefore(monday) && !d.isAfter(sunday) + } + + val countsByDate = recordsInThisWeek + .groupBy { it.originalDate } + .mapValues { it.value.size } + + val calendarDays = weekRange.map { date -> + val dateString = date.toString() + CalendarDay( + dayLabel = date.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN), + date = date.dayOfMonth, + style = when { + countsByDate.containsKey(dateString) -> CalendarDayStyle.FILLED + date.isEqual(today) -> CalendarDayStyle.TODAY + else -> CalendarDayStyle.OUTLINED + } + ) + } + + return WeeklySummaryUiState( + calendarDays = calendarDays, + totalWeeklyCount = recordsInThisWeek.size, + dayCounts = countsByDate ) + } private fun formatDate(date: String): String { - // "2026-02-06" → "2월 6일" 같은 변환 val parts = date.split("-") return "${parts[1].toInt()}월 ${parts[2].toInt()}일" } + + // --- Public Functions (Business Logic) --- + fun loadRecords( type: String, view: String = "LIST", @@ -119,75 +183,78 @@ class MindRecordViewModel @Inject constructor( try { val result = getMindRecordsUseCase(type, view, year, month) val domainRecords = result.records - val apiMarked = result.markedDates.toSet() - val fallbackFromRecords = domainRecords.map { it.date }.toSet() - _markedDates.value = - if (apiMarked.isNotEmpty()) apiMarked else fallbackFromRecords - _records.value = domainRecords.map { summary -> + val uiModels = domainRecords.map { summary -> MindRecordUiModel( id = summary.recordId, title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) } - .getOrElse { summary.date }, + formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date }, draftLabel = if (summary.isDraft) "임시저장" else "완료", content = summary.content, originalDate = summary.date, type = summary.type ) } + + _records.value = uiModels + + // [추가] 데일리 질문인 경우 리포트용 전용 상태 업데이트 + if (type == "DAILY_QUESTION") { + _dailyQuestionRecords.value = uiModels + } + + val apiMarked = result.markedDates.toSet() + _markedDates.value = if (apiMarked.isNotEmpty()) apiMarked else uiModels.map { it.originalDate }.toSet() } catch (e: Exception) { - Log.e("MindRecordViewModel", "loadRecords 실패", e) + Log.e(TAG, "loadRecords 실패", e) _records.value = emptyList() - _markedDates.value = emptySet() } } } - /** - * 일기(DIARY)와 깊은 생각(DEEP_THOUGHT)을 모두 조회하여 합칩니다. - * RecordFirstDiaryListScreen에서 사용합니다. - */ fun loadRecordsForDiaryList() { viewModelScope.launch { try { val diaryResult = getMindRecordsUseCase("DIARY", "LIST", null, null) val deepResult = getMindRecordsUseCase("DEEP_THOUGHT", "LIST", null, null) - val combined = - diaryResult.records + deepResult.records + val combined = diaryResult.records + deepResult.records val sorted = combined.sortedByDescending { it.date } - val apiMarkedDates = - (diaryResult.markedDates + deepResult.markedDates).toSet() - val fallbackFromRecords = sorted.map { it.date }.toSet() - _markedDates.value = - if (apiMarkedDates.isNotEmpty()) apiMarkedDates else fallbackFromRecords - _records.value = sorted.map { summary -> + + val uiModels = sorted.map { summary -> MindRecordUiModel( id = summary.recordId, title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) } - .getOrElse { summary.date }, + formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date }, draftLabel = if (summary.isDraft) "임시저장" else "완료", content = summary.content, originalDate = summary.date, type = summary.type ) } + + _records.value = uiModels + // [추가] 전체 기록 리포트용 상태 업데이트 + _totalRecords.value = uiModels + + val apiMarkedDates = (diaryResult.markedDates + deepResult.markedDates).toSet() + _markedDates.value = if (apiMarkedDates.isNotEmpty()) apiMarkedDates else uiModels.map { it.originalDate }.toSet() } catch (e: Exception) { - Log.e("MindRecordViewModel", "loadRecordsForDiaryList 실패", e) + Log.e(TAG, "loadRecordsForDiaryList 실패", e) _records.value = emptyList() - _markedDates.value = emptySet() } } } - // UI 상태를 관리할 StateFlow + // [추가] 두 가지 리포트 데이터를 한 번에 갱신하는 함수 + fun loadAllReportData() { + loadRecords("DAILY_QUESTION") + loadRecordsForDiaryList() + } + + // --- 나머지 기존 함수 유지 --- + private val _uiState = MutableStateFlow(MindRecordUiState()) val uiState: StateFlow = _uiState - /** - * 오늘의 데일리 질문을 조회하여 UI에 표시합니다. - * RecordQuestionScreen 진입 시(record == null) 호출합니다. - */ fun loadDailyQuestion() { viewModelScope.launch { val data = getDailyQuestionUseCase() @@ -201,9 +268,7 @@ class MindRecordViewModel @Inject constructor( params.type == "DAILY_QUESTION" && params.questionId == null -> { val data = getDailyQuestionUseCase() if (data == null) { - _uiState.update { - it.copy(createErrorMessage = "오늘의 질문을 불러올 수 없습니다.") - } + _uiState.update { it.copy(createErrorMessage = "오늘의 질문을 불러올 수 없습니다.") } return@launch } data.questionId @@ -223,17 +288,13 @@ class MindRecordViewModel @Inject constructor( onSuccess = { _uiState.update { it.copy(createErrorMessage = null) } loadRecords(params.type) + // 기록 생성 후 리포트 데이터도 함께 갱신하여 정합성 유지 + loadAllReportData() onSuccess() }, onFailure = { e -> - Log.e("MindRecordViewModel", "onCreateRecord 실패", e) - val message = when (e) { - is HttpException -> when (e.code()) { - 401 -> "인증이 만료되었습니다. 다시 로그인해 주세요." - else -> e.message() ?: "등록에 실패했습니다." - } - else -> e.message ?: "등록에 실패했습니다." - } + Log.e(TAG, "onCreateRecord 실패", e) + val message = if (e is HttpException && e.code() == 401) "인증 만료" else e.message ?: "등록 실패" _uiState.update { it.copy(createErrorMessage = message) } } ) @@ -244,34 +305,15 @@ class MindRecordViewModel @Inject constructor( _uiState.update { it.copy(createErrorMessage = null) } } - /** - * 기록을 삭제하고 목록을 갱신합니다. - * - * @param recordId 삭제할 기록 ID - * @param recordType 기록 유형 (갱신 시 loadRecords에 사용) - * @param onReload 삭제 성공 후 목록 갱신 콜백. null이면 loadRecords(recordType) 호출. - * 일기 목록(DIARY+DEEP_THOUGHT 통합)에서는 loadRecordsForDiaryList 전달. - * @param onSuccess 삭제 성공 시 콜백 - */ - fun deleteRecord( - recordId: Long, - recordType: String, - onReload: (() -> Unit)? = null, - onSuccess: () -> Unit = {} - ) { + fun deleteRecord(recordId: Long, recordType: String, onReload: (() -> Unit)? = null, onSuccess: () -> Unit = {}) { viewModelScope.launch { deleteMindRecordUseCase(recordId).fold( onSuccess = { - if (onReload != null) { - onReload() - } else { - loadRecords(recordType) - } + if (onReload != null) onReload() else loadRecords(recordType) + loadAllReportData() // 삭제 후 통계 갱신 onSuccess() }, - onFailure = { e -> - Log.e(TAG, "deleteRecord failed recordId=$recordId", e) - } + onFailure = { e -> Log.e(TAG, "deleteRecord failed", e) } ) } } @@ -282,28 +324,22 @@ class MindRecordViewModel @Inject constructor( fun loadRecord(recordId: Long) { viewModelScope.launch { try { - // UseCase 호출 → Result val response = getMindRecordUseCase(recordId).getOrThrow() - val detail = response.data // 실제 기록 정보는 여기 들어있음 - + val detail = response.data if (detail != null) { _selectedRecord.value = MindRecordUiModel( id = detail.recordId, title = detail.title, - formattedDate = runCatching { formatDate(detail.date) } - .getOrElse { detail.date }, + formattedDate = runCatching { formatDate(detail.date) }.getOrElse { detail.date }, draftLabel = if (detail.isDraft) "임시저장" else "완료", content = detail.content, type = detail.type, category = detail.category, originalDate = detail.date ) - } else { - _selectedRecord.value = null } } catch (e: Exception) { - Log.e(TAG, "loadRecord failed recordId=$recordId", e) - _selectedRecord.value = null + Log.e(TAG, "loadRecord failed", e) } } } @@ -312,27 +348,42 @@ class MindRecordViewModel @Inject constructor( viewModelScope.launch { try { val request = PostMindRecordRequest( - params.title, - params.content, - params.date, - params.type, - params.isDraft, - questionId = null, - params.category + params.title, params.content, params.date, params.type, params.isDraft, null, params.category ) editMindRecordUseCase(params.recordId, request).getOrThrow() loadRecords(params.type) + loadAllReportData() // 수정 후 통계 갱신 onSuccess() } catch (e: Exception) { - Log.e(TAG, "editRecord failed recordId=${params.recordId}", e) + Log.e(TAG, "editRecord failed", e) } } } - - } +fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { + // 1. 날짜 포맷팅 로직 (안정성을 위해 java.time 사용) + val inputFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy.MM.dd") + val isoFormatter = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd + val parsedDate = runCatching { + java.time.LocalDate.parse(this.date, inputFormatter) + }.getOrElse { + java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) + } + val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" + val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" - + return MindRecordUiModel( + // String ID를 Long으로 변환 (UI용 unique key 확보) + id = this.id.hashCode().toLong(), + title = this.serviceName, + formattedDate = formattedDateStr, + draftLabel = "완료", // 애프터노트는 도메인상 완료된 데이터로 간주 + content = this.message, + type = this.type.name, + category = null, // 필요 시 mapping 로직 추가 + originalDate = originalDateStr + ) +}