diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/repository/iface/GetMindRecordDetailByAuthCodeRepository.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/repository/iface/GetMindRecordDetailByAuthCodeRepository.kt new file mode 100644 index 00000000..5f7ea9eb --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/repository/iface/GetMindRecordDetailByAuthCodeRepository.kt @@ -0,0 +1,21 @@ +package com.kuit.afternote.feature.receiver.domain.repository.iface + +/** + * 인증번호로 특정 마인드레코드 상세의 대표 이미지 URL을 조회하는 Domain Repository. + * + * GET /api/receiver-auth/mind-records/{mindRecordId} (X-Auth-Code) API를 사용합니다. + */ +interface GetMindRecordDetailByAuthCodeRepository { + + /** + * 마인드레코드 상세의 이미지 목록에서 첫 번째 이미지 URL을 반환합니다. + * 이미지가 없으면 null을 반환합니다. + * + * @param authCode 수신자 인증번호 (X-Auth-Code 헤더) + * @param mindRecordId 마인드레코드 ID + */ + suspend fun getMindRecordDetailImageUrl( + authCode: String, + mindRecordId: Long + ): Result +} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/usecase/GetMindRecordDetailByAuthCodeUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/usecase/GetMindRecordDetailByAuthCodeUseCase.kt new file mode 100644 index 00000000..c5003e6a --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/usecase/GetMindRecordDetailByAuthCodeUseCase.kt @@ -0,0 +1,31 @@ +package com.kuit.afternote.feature.receiver.domain.usecase + +import com.kuit.afternote.feature.receiver.domain.repository.iface.GetMindRecordDetailByAuthCodeRepository +import javax.inject.Inject + +/** + * 수신자 인증번호(authCode)와 마인드레코드 ID로 상세의 대표 이미지 URL을 조회하는 UseCase. + * + * GET /api/receiver-auth/mind-records/{mindRecordId} (X-Auth-Code) API를 사용합니다. + */ +class GetMindRecordDetailByAuthCodeUseCase + @Inject + constructor( + private val getMindRecordDetailByAuthCodeRepository: GetMindRecordDetailByAuthCodeRepository + ) { + + /** + * 마인드레코드 상세의 첫 번째 이미지 URL을 반환합니다. 없으면 null. + * + * @param authCode 수신자 인증번호 + * @param mindRecordId 마인드레코드 ID + */ + suspend operator fun invoke( + authCode: String, + mindRecordId: Long + ): Result = + getMindRecordDetailByAuthCodeRepository.getMindRecordDetailImageUrl( + authCode = authCode, + mindRecordId = mindRecordId + ) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/TodayRecordCard.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/TodayRecordCard.kt index c1e6b94f..fe0bf93f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/TodayRecordCard.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/component/TodayRecordCard.kt @@ -1,5 +1,7 @@ package com.kuit.afternote.feature.receiver.presentation.component +import android.util.Log +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,16 +21,38 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.kuit.afternote.R import com.kuit.afternote.feature.receiver.presentation.uimodel.MindRecordItemUiModel import com.kuit.afternote.ui.theme.Sansneo +private const val TAG = "TodayRecordCard" + +/** 이미지 없을 때 오늘의 기록 카드 배경용 디폴트 테마 (TimeLetterBlockItem과 동일한 비주얼). */ +private object TodayRecordCardDefaultTheme { + val backgroundColor: Color = Color(0xFFFFE1CC) + val logoResId: Int = R.drawable.logo_yellow +} + @Composable -fun TodayRecordCard(todayRecord: MindRecordItemUiModel? = null) { +fun TodayRecordCard( + todayRecord: MindRecordItemUiModel? = null, + todayRecordImageUrl: String? = null +) { + val hasImage = !todayRecordImageUrl.isNullOrBlank() + Card( modifier = Modifier .fillMaxWidth() @@ -37,21 +61,69 @@ fun TodayRecordCard(todayRecord: MindRecordItemUiModel? = null) { elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Box { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.DarkGray) - ) - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)) + if (hasImage) { + val context = LocalContext.current + AsyncImage( + model = ImageRequest.Builder(context) + .data(todayRecordImageUrl) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + error = painterResource(R.drawable.img_time_letter_placeholder), + onError = { state: AsyncImagePainter.State.Error -> + Log.e(TAG, "Coil load failed: url=$todayRecordImageUrl", state.result.throwable) + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color(0x660E0E0E) + ) + ) ) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background( + TodayRecordCardDefaultTheme.backgroundColor.copy(alpha = 0.2f) + ) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.00f to Color(0x000E0E0E), + 0.09f to Color(0x170E0E0E), + 0.45f to Color(0x730E0E0E), + 1.00f to Color(0x800E0E0E) + ) + ) + ) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = TodayRecordCardDefaultTheme.logoResId), + contentDescription = stringResource(R.string.receiver_mindrecord_card_logo) ) - ) + } + } Column( modifier = Modifier @@ -102,3 +174,25 @@ fun TodayRecordCard(todayRecord: MindRecordItemUiModel? = null) { } } } + +@Preview(showBackground = true) +@Composable +private fun PreviewTodayRecordCardDefault() { + TodayRecordCard( + todayRecord = MindRecordItemUiModel( + mindRecordId = 1L, + date = "2025년 2월 19일", + tags = "#DIARY", + question = "오늘 하루는 어땠나요?", + content = "기록 내용", + hasImage = false + ), + todayRecordImageUrl = null + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewTodayRecordCardEmpty() { + TodayRecordCard(todayRecord = null, todayRecordImageUrl = null) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/mindrecord/ReceiverDailyRecordScreen.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/mindrecord/ReceiverDailyRecordScreen.kt index c8cc4aa1..cf3e2c1e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/mindrecord/ReceiverDailyRecordScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/mindrecord/ReceiverDailyRecordScreen.kt @@ -156,7 +156,10 @@ fun MindRecordScreen( ) } Spacer(modifier = Modifier.height(12.dp)) - TodayRecordCard(todayRecord = uiState.todayRecord) + TodayRecordCard( + todayRecord = uiState.todayRecord, + todayRecordImageUrl = uiState.todayRecordImageUrl + ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/timeletter/ReceiverTimeLetterDetailScreen.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/timeletter/ReceiverTimeLetterDetailScreen.kt index 63d81ad6..c5b3e72f 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/timeletter/ReceiverTimeLetterDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/timeletter/ReceiverTimeLetterDetailScreen.kt @@ -1,15 +1,18 @@ package com.kuit.afternote.feature.receiver.presentation.screen.timeletter -import androidx.compose.foundation.background +import android.util.Log +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize 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.layout.statusBarsPadding +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -21,20 +24,33 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import com.kuit.afternote.R 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.receiver.domain.entity.ReceivedTimeLetter +import com.kuit.afternote.feature.receiver.domain.entity.ReceivedTimeLetterMedia import com.kuit.afternote.feature.receiver.presentation.uimodel.ReceiverTimeLetterDetailUiState import com.kuit.afternote.ui.theme.Gray4 import com.kuit.afternote.ui.theme.Gray9 import com.kuit.afternote.ui.theme.Sansneo +private const val TAG = "ReceiverTimeLetterDetailScreen" +private const val DETAIL_IMAGE_SIZE_DP = 100 + @Composable fun TimeLetterDetailScreen( uiState: ReceiverTimeLetterDetailUiState, @@ -121,22 +137,17 @@ private fun TimeLetterDetailContent( fontFamily = Sansneo ) - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.6f) - .clip(RoundedCornerShape(16.dp)) - .background(Color.LightGray) - ) { - Text( - text = "가족 사진 영역", - color = Color.DarkGray, - modifier = Modifier.align(Alignment.Center) + val imageUrls = letter.mediaList + .filter { it.mediaType == "IMAGE" } + .map { it.mediaUrl } + if (imageUrls.isNotEmpty()) { + ReceiverDetailMediaRow( + mediaUrls = imageUrls, + modifier = Modifier.fillMaxWidth() ) + Spacer(modifier = Modifier.height(24.dp)) } - Spacer(modifier = Modifier.height(24.dp)) - Text( text = letter.content.orEmpty().ifEmpty { " " }, color = Gray9, @@ -150,6 +161,44 @@ private fun TimeLetterDetailContent( } } +@Composable +private fun ReceiverDetailMediaRow( + mediaUrls: List, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val imageSizeDp = DETAIL_IMAGE_SIZE_DP.dp + val contentDesc = stringResource(R.string.content_description_time_letter_media) + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + mediaUrls.forEach { url -> + AsyncImage( + model = ImageRequest.Builder(context) + .data(url) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = contentDesc, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(imageSizeDp) + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.img_time_letter_placeholder), + onError = { state: AsyncImagePainter.State.Error -> + Log.e(TAG, "Coil load failed: url=$url", state.result.throwable) + } + ) + } + } +} + @Preview(showBackground = true) @Composable private fun PreviewTimeLetterDetail() { @@ -177,3 +226,39 @@ private fun PreviewTimeLetterDetail() { ) } } + +@Preview(showBackground = true) +@Composable +private fun PreviewTimeLetterDetailWithMedia() { + MaterialTheme { + val sampleLetter = ReceivedTimeLetter( + timeLetterId = 1L, + timeLetterReceiverId = 100L, + title = "채연아 20번째 생일을 축하해", + content = "사랑한다 생일 축하해 내 딸.", + sendAt = "2027년 11월 24일", + status = "DRAFT", + senderName = "박채연", + deliveredAt = null, + createdAt = null, + mediaList = listOf( + ReceivedTimeLetterMedia( + id = 1L, + mediaType = "IMAGE", + mediaUrl = "https://example.com/sample1.jpg" + ), + ReceivedTimeLetterMedia( + id = 2L, + mediaType = "IMAGE", + mediaUrl = "https://example.com/sample2.jpg" + ) + ), + isRead = false + ) + TimeLetterDetailScreen( + uiState = ReceiverTimeLetterDetailUiState(letter = sampleLetter), + onBackClick = { }, + onBottomNavSelected = { } + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/MindRecordListUiState.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/MindRecordListUiState.kt index 530cec99..d2b8836a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/MindRecordListUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/MindRecordListUiState.kt @@ -10,6 +10,8 @@ data class MindRecordListUiState( val daysWithRecords: Set = emptySet(), val selectedDateRecords: List = emptyList(), val todayRecord: MindRecordItemUiModel? = null, + /** 오늘의 기록 카드용 대표 이미지 URL. 상세 API imageList 첫 번째 항목. */ + val todayRecordImageUrl: String? = null, val isLoading: Boolean = false, val errorMessage: String? = null, val showDatePicker: Boolean = false diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/MindRecordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/MindRecordViewModel.kt index 8a147d04..cc6c78dd 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/MindRecordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/MindRecordViewModel.kt @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.receiver.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kuit.afternote.feature.receiver.domain.entity.ReceivedMindRecord +import com.kuit.afternote.feature.receiver.domain.usecase.GetMindRecordDetailByAuthCodeUseCase import com.kuit.afternote.feature.receiver.domain.usecase.GetMindRecordsByAuthCodeUseCase import com.kuit.afternote.feature.receiverauth.session.ReceiverAuthSessionHolder import com.kuit.afternote.feature.receiver.presentation.uimodel.MindRecordItemUiModel @@ -30,7 +31,8 @@ class MindRecordViewModel @Inject constructor( private val receiverAuthSessionHolder: ReceiverAuthSessionHolder, - private val getMindRecordsByAuthCodeUseCase: GetMindRecordsByAuthCodeUseCase + private val getMindRecordsByAuthCodeUseCase: GetMindRecordsByAuthCodeUseCase, + private val getMindRecordDetailByAuthCodeUseCase: GetMindRecordDetailByAuthCodeUseCase ) : ViewModel(), MindRecordViewModelContract { private val _uiState = MutableStateFlow(MindRecordListUiState()) @@ -45,6 +47,7 @@ class MindRecordViewModel getMindRecordsByAuthCodeUseCase(authCode) .onSuccess { data -> allMindRecords = data.items + val today = findTodayRecord(data.items) _uiState.update { state -> state.copy( daysWithRecords = computeDaysWithRecords( @@ -52,11 +55,13 @@ class MindRecordViewModel YearMonth.from(state.selectedDate) ), selectedDateRecords = filterAndMapToUiModels(data.items, state.selectedDate), - todayRecord = findTodayRecord(data.items), + todayRecord = today, + todayRecordImageUrl = null, isLoading = false, errorMessage = null ) } + today?.let { loadTodayRecordImage(authCode, it.mindRecordId) } } .onFailure { e -> _uiState.update { @@ -90,6 +95,15 @@ class MindRecordViewModel _uiState.update { it.copy(errorMessage = null) } } + private fun loadTodayRecordImage(authCode: String, mindRecordId: Long) { + viewModelScope.launch { + getMindRecordDetailByAuthCodeUseCase(authCode, mindRecordId) + .onSuccess { url -> + _uiState.update { it.copy(todayRecordImageUrl = url) } + } + } + } + private fun computeDaysWithRecords( list: List, displayYearMonth: YearMonth diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/di/ReceiverAuthModule.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/di/ReceiverAuthModule.kt index bb94f7e4..2f00c426 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/di/ReceiverAuthModule.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/di/ReceiverAuthModule.kt @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.receiverauth.data.di import com.kuit.afternote.feature.receiver.domain.repository.iface.GetAfternoteDetailByAuthCodeRepository import com.kuit.afternote.feature.receiver.domain.repository.iface.GetAfterNotesByAuthCodeRepository import com.kuit.afternote.feature.receiver.domain.repository.iface.GetSenderMessageRepository +import com.kuit.afternote.feature.receiver.domain.repository.iface.GetMindRecordDetailByAuthCodeRepository import com.kuit.afternote.feature.receiver.domain.repository.iface.GetMindRecordsByAuthCodeRepository import com.kuit.afternote.feature.receiver.domain.repository.iface.GetTimeLetterDetailByAuthCodeRepository import com.kuit.afternote.feature.receiver.domain.repository.iface.GetTimeLettersByAuthCodeRepository @@ -11,6 +12,7 @@ import com.kuit.afternote.feature.receiverauth.data.repository.impl.GetDeliveryV import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthAfternoteDetailRepositoryImpl import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthAfternoteRepositoryImpl import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthMessageRepositoryImpl +import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthMindRecordDetailRepositoryImpl import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthMindRecordRepositoryImpl import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthRepositoryImpl import com.kuit.afternote.feature.receiverauth.data.repository.impl.ReceiverAuthTimeLetterDetailRepositoryImpl @@ -65,6 +67,12 @@ abstract class ReceiverAuthModule { impl: ReceiverAuthMindRecordRepositoryImpl ): GetMindRecordsByAuthCodeRepository + @Binds + @Singleton + abstract fun bindGetMindRecordDetailByAuthCodeRepository( + impl: ReceiverAuthMindRecordDetailRepositoryImpl + ): GetMindRecordDetailByAuthCodeRepository + @Binds @Singleton abstract fun bindGetAfterNotesByAuthCodeRepository( diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthMindRecordDetailRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthMindRecordDetailRepositoryImpl.kt new file mode 100644 index 00000000..8a44b271 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/repository/impl/ReceiverAuthMindRecordDetailRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.kuit.afternote.feature.receiverauth.data.repository.impl + +import com.kuit.afternote.feature.receiver.domain.repository.iface.GetMindRecordDetailByAuthCodeRepository +import com.kuit.afternote.feature.receiverauth.data.repository.iface.ReceiverAuthRepository +import javax.inject.Inject + +/** + * 인증번호 기반 마인드레코드 상세의 대표 이미지 URL 조회 Repository 구현. + * + * GET /api/receiver-auth/mind-records/{mindRecordId} (X-Auth-Code) API를 사용합니다. + */ +class ReceiverAuthMindRecordDetailRepositoryImpl + @Inject + constructor( + private val receiverAuthRepository: ReceiverAuthRepository + ) : GetMindRecordDetailByAuthCodeRepository { + + override suspend fun getMindRecordDetailImageUrl( + authCode: String, + mindRecordId: Long + ): Result = + receiverAuthRepository.getMindRecordDetail( + authCode = authCode, + mindRecordId = mindRecordId + ).map { dto -> + dto.imageList.firstOrNull()?.imageUrl + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt index 2bfab0a2..1961a448 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/presentation/viewmodel/VerifySelfViewModel.kt @@ -13,6 +13,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.IOException @@ -36,6 +38,8 @@ class VerifySelfViewModel private val _uiState = MutableStateFlow(VerifySelfUiState()) override val uiState: StateFlow = _uiState.asStateFlow() + private var deliveryStatusPollingJob: Job? = null + /** * 마스터 키 입력값 갱신. 에러 메시지 초기화. */ @@ -103,6 +107,10 @@ class VerifySelfViewModel val current = _uiState.value.currentStep val previous = current.previous() if (previous != null) { + if (current == VerifyStep.END) { + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = null + } _uiState.update { it.copy(currentStep = previous, verifyError = null) } } return previous @@ -168,6 +176,14 @@ class VerifySelfViewModel ) } loadDeliveryVerificationStatus(authCode) + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = viewModelScope.launch { + while (_uiState.value.currentStep == VerifyStep.END) { + delay(POLLING_INTERVAL_MS) + if (_uiState.value.currentStep != VerifyStep.END) break + loadDeliveryVerificationStatus(authCode) + } + } } .onFailure { e -> Log.e(TAG, "Submit delivery verification failed", e) @@ -185,6 +201,11 @@ class VerifySelfViewModel viewModelScope.launch { getDeliveryVerificationStatusUseCase(authCode) .onSuccess { status -> + val isApproved = status.status.equals("APPROVED", ignoreCase = true) + if (isApproved) { + deliveryStatusPollingJob?.cancel() + deliveryStatusPollingJob = null + } _uiState.update { it.copy(deliveryVerificationStatus = status) } @@ -207,5 +228,6 @@ class VerifySelfViewModel companion object { private const val TAG = "VerifySelfViewModel" + private const val POLLING_INTERVAL_MS = 5000L } } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/screen/VerifySelfScreen.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/screen/VerifySelfScreen.kt index b3daf147..cbbaa77c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/screen/VerifySelfScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/screen/VerifySelfScreen.kt @@ -21,12 +21,15 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -49,6 +52,7 @@ import com.kuit.afternote.feature.receiverauth.presentation.viewmodel.VerifySelf import com.kuit.afternote.feature.receiverauth.uimodel.VerifyStep import com.kuit.afternote.ui.theme.B3 import com.kuit.afternote.ui.theme.Red +import kotlinx.coroutines.launch private enum class PendingDocumentTarget { DEATH_CERT, @@ -70,6 +74,9 @@ fun VerifySelfScreen( val pendingDocumentTargetState = remember { mutableStateOf(null) } val deathCertificateUriState = remember { mutableStateOf(null) } val familyCertificateUriState = remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val pendingMessage = stringResource(R.string.receiver_verify_wait_for_approval) val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() @@ -122,7 +129,8 @@ fun VerifySelfScreen( step = step ) } - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> Column( modifier = Modifier @@ -150,7 +158,12 @@ fun VerifySelfScreen( ) VerifyStep.END -> VerifySelfEndStep( uiState = uiState, - onCompleteClick = onCompleteClick + onCompleteClick = onCompleteClick, + onShowPendingMessage = { + scope.launch { + snackbarHostState.showSnackbar(pendingMessage) + } + } ) } } @@ -286,13 +299,19 @@ private fun VerifySelfPdfStep( @Composable private fun VerifySelfEndStep( uiState: VerifySelfUiState, - onCompleteClick: (receiverId: Long, authCode: String, senderName: String?) -> Unit + onCompleteClick: (receiverId: Long, authCode: String, senderName: String?) -> Unit, + onShowPendingMessage: () -> Unit ) { SignUpContentButton( onNextClick = { val rid = uiState.verifiedReceiverId - if (rid != null) { + if (rid == null) return@SignUpContentButton + val isApproved = uiState.deliveryVerificationStatus?.status + ?.equals("APPROVED", ignoreCase = true) == true + if (isApproved) { onCompleteClick(rid, uiState.masterKeyInput, uiState.verifiedSenderName) + } else { + onShowPendingMessage() } }, buttonTitle = stringResource(R.string.receiver_verify_complete_confirm) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavConstants.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavConstants.kt new file mode 100644 index 00000000..9fb002f3 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavConstants.kt @@ -0,0 +1,6 @@ +package com.kuit.afternote.feature.timeletter.presentation.navgraph + +/** + * 수신자 목록에서 선택한 수신자 ID를 이전 화면(타임레터 목록/작성)으로 전달할 때 사용하는 키. + */ +const val SELECTED_RECEIVER_ID_KEY = "selected_receiver_id" diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt index a29d6b19..c9f03e89 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt @@ -34,7 +34,13 @@ fun NavGraphBuilder.timeLetterNavGraph( onBackClick = { navController.popBackStack() }, onNavItemSelected = onNavItemSelected, onAddClick = { navController.navigate(TimeLetterRoute.TimeLetterWriterRoute()) }, - onShowAllClick = { navController.navigate(ReceiverRoute.ReceiverListRoute) }, + onShowAllClick = { + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove(SELECTED_RECEIVER_ID_KEY) + navController.navigate(ReceiverRoute.ReceiverListRoute) + }, + onLetterClick = { letter -> navController.navigate( TimeLetterRoute.TimeLetterDetailRoute( diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt index 44ff04ba..ea4ab368 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt @@ -141,7 +141,9 @@ fun TimeLetterScreen( } is TimeLetterUiState.Success -> { - val letters = (uiState as TimeLetterUiState.Success).letters + val successState = uiState as TimeLetterUiState.Success + val letters = successState.letters + val filterLabel = successState.selectedReceiverName ?: "전체보기" // 필터 + 토글 Row (좌우 패딩은 ScaffoldContentWithOptionalFab contentPadding에서 적용됨) Row( @@ -153,7 +155,7 @@ fun TimeLetterScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "전체보기", + text = filterLabel, fontFamily = FontFamily(Font(R.font.sansneomedium)), fontSize = 16.sp, fontWeight = FontWeight.Medium diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt index 47c87376..d2f3bbf4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt @@ -8,7 +8,9 @@ sealed class TimeLetterUiState { object Empty : TimeLetterUiState() data class Success( - val letters: List + val letters: List, + /** 수신자 필터 적용 시 해당 수신자 이름. null이면 "전체보기" 표시 */ + val selectedReceiverName: String? = null ) : TimeLetterUiState() } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt index 15155c47..cbb7cf34 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt @@ -1,7 +1,9 @@ package com.kuit.afternote.feature.timeletter.presentation.viewmodel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kuit.afternote.feature.timeletter.presentation.navgraph.SELECTED_RECEIVER_ID_KEY import com.kuit.afternote.feature.timeletter.domain.model.TimeLetter import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterMediaType import com.kuit.afternote.feature.timeletter.domain.usecase.DeleteTimeLettersUseCase @@ -33,7 +35,8 @@ class TimeLetterViewModel private val getTimeLettersUseCase: GetTimeLettersUseCase, private val deleteTimeLettersUseCase: DeleteTimeLettersUseCase, private val getUserIdUseCase: GetUserIdUseCase, - private val getReceiversUseCase: GetReceiversUseCase + private val getReceiversUseCase: GetReceiversUseCase, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val _viewMode = MutableStateFlow(ViewMode.LIST) val viewMode: StateFlow = _viewMode.asStateFlow() @@ -50,21 +53,36 @@ class TimeLetterViewModel /** * 타임레터 목록 로드 (GET /time-letters) - * 수신자 목록(GET /users/receivers)으로 receiverIds → 이름 매핑 후 표시 + * 수신자 목록(GET /users/receivers)으로 receiverIds → 이름 매핑 후 표시. + * SavedStateHandle에 선택 수신자 ID가 있으면 해당 수신자로 필터링하고 헤더에 이름을 노출한다. */ private fun loadLetters() { viewModelScope.launch { _uiState.value = TimeLetterUiState.Loading val receivers = loadReceiversOrEmpty() + val selectedReceiverId = savedStateHandle.get(SELECTED_RECEIVER_ID_KEY) getTimeLettersUseCase() .onSuccess { list -> - val items = list.timeLetters.mapIndexed { index, timeLetter -> + val filteredLetters = + if (selectedReceiverId != null) { + list.timeLetters.filter { it.receiverIds.contains(selectedReceiverId) } + } else { + list.timeLetters + } + val items = filteredLetters.mapIndexed { index, timeLetter -> toTimeLetterItem(timeLetter, index, receivers) } + val selectedReceiverName = + selectedReceiverId?.let { id -> + receivers.find { it.receiverId == id }?.name + } _uiState.value = if (items.isEmpty()) { TimeLetterUiState.Empty } else { - TimeLetterUiState.Success(items) + TimeLetterUiState.Success( + letters = items, + selectedReceiverName = selectedReceiverName + ) } }.onFailure { _uiState.value = TimeLetterUiState.Empty diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94595034..55d24575 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ 프로필 이미지 프로필 수정 버튼 앨범 커버 + 타임레터 첨부 이미지 나의 모든 기록 님에게 전달 될 @@ -59,6 +60,7 @@ 확인하기 이 기록은 아직 확인할 수 없어요 오늘의 기록이 없어요 + 오늘의 기록 카드 로고 소셜 네트워크 수신자 인증 @@ -86,6 +88,7 @@ 서류 촬영 또는 파일 첨부 이미지 추가 파일 추가 + 인증이 완료된 후에 이용 가능합니다. 카카오로 로그인 카카오 토큰을 발급받았습니다. 서버 연동(/auth/kakao) 대기 중입니다.