diff --git a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt index e701ac7a..f005740d 100644 --- a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt @@ -12,16 +12,27 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text 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 import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity @@ -34,7 +45,10 @@ import com.kuit.afternote.R import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.app.di.ReceiverAuthSessionEntryPoint import com.kuit.afternote.app.di.TokenManagerEntryPoint +import com.kuit.afternote.core.ui.component.list.AfternoteTab import com.kuit.afternote.core.ui.component.navigation.BottomNavItem +import com.kuit.afternote.core.ui.util.getAfternoteDisplayRes +import com.kuit.afternote.core.ui.util.getIconResForServiceName import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem import com.kuit.afternote.feature.afternote.presentation.navgraph.AfternoteEditStateHandling @@ -58,6 +72,7 @@ import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverTimeLet import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverTimeLetterRoute import com.kuit.afternote.feature.receiver.presentation.screen.afternote.ReceiverAfternoteListEvent import com.kuit.afternote.feature.receiver.presentation.uimodel.ReceiverAfternoteListUiState +import com.kuit.afternote.feature.receiver.presentation.viewmodel.ReceiverAfternotesListViewModel import com.kuit.afternote.feature.receiverauth.screen.ReceiverOnboardingScreen import com.kuit.afternote.feature.receiverauth.screen.VerifySelfScreen import com.kuit.afternote.feature.setting.presentation.navgraph.SettingRoute @@ -177,36 +192,82 @@ private fun FingerprintLoginRouteContent( @Composable private fun ReceiverAfternoteListRouteContent(navHostController: NavHostController) { - val receiverProvider = DataProviderLocals.LocalReceiverDataProvider.current BackHandler { navHostController.popBackStack() } - val afternoteItems = remember(receiverProvider) { - receiverProvider.getAfternoteListSeedsForReceiverList().map { seed -> - AfternoteListDisplayItem( - id = seed.id, - serviceName = seed.serviceNameLiteral ?: "", - date = seed.date, - iconResId = seed.iconResId - ) + val viewModel: ReceiverAfternotesListViewModel = hiltViewModel() + val afterNotesState by viewModel.uiState.collectAsStateWithLifecycle() + var selectedTab by remember { mutableStateOf(AfternoteTab.ALL) } + var selectedBottomNavItem by remember { mutableStateOf(BottomNavItem.AFTERNOTE) } + val filteredItems = remember(afterNotesState.items, selectedTab) { + val list = afterNotesState.items + when (selectedTab) { + AfternoteTab.ALL -> list + AfternoteTab.SOCIAL_NETWORK -> list.filter { + it.sourceType.equals("SOCIAL", ignoreCase = true) + } + AfternoteTab.GALLERY_AND_FILES -> list.filter { + it.sourceType.equals("GALLERY", ignoreCase = true) + } + AfternoteTab.MEMORIAL -> list.filter { + it.sourceType.equals("PLAYLIST", ignoreCase = true) + } } } - var listState by remember { - mutableStateOf(ReceiverAfternoteListUiState(items = afternoteItems)) + val displayItems = filteredItems.map { item -> + val iconResId = + if (item.title.isNotBlank()) getIconResForServiceName(item.title) + else getAfternoteDisplayRes(item.sourceType).second + AfternoteListDisplayItem( + id = item.id.toString(), + serviceName = item.title, + date = item.lastUpdatedAt, + iconResId = iconResId + ) } - ReceiverAfternoteListRoute( - uiState = listState, - onEvent = { event -> - listState = when (event) { - is ReceiverAfternoteListEvent.SelectTab -> - listState.copy(selectedTab = event.tab) - is ReceiverAfternoteListEvent.SelectBottomNav -> - listState.copy(selectedBottomNavItem = event.navItem) - is ReceiverAfternoteListEvent.ClickItem -> { - navHostController.navigate("receiver_afternote_detail/${event.itemId}") - listState + val listState = ReceiverAfternoteListUiState( + items = displayItems, + selectedTab = selectedTab, + selectedBottomNavItem = selectedBottomNavItem + ) + when { + afterNotesState.isLoading && displayItems.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + afterNotesState.errorMessage != null && displayItems.isEmpty() -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(PaddingValues(24.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = afterNotesState.errorMessage!!) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.retry() }) { + Text(stringResource(R.string.retry)) } } } - ) + else -> { + ReceiverAfternoteListRoute( + uiState = listState, + onEvent = { event -> + when (event) { + is ReceiverAfternoteListEvent.SelectTab -> + selectedTab = event.tab + is ReceiverAfternoteListEvent.SelectBottomNav -> + selectedBottomNavItem = event.navItem + is ReceiverAfternoteListEvent.ClickItem -> + navHostController.navigate("receiver_afternote_detail/${event.itemId}") + } + } + ) + } + } } @Composable diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternote.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternote.kt index 5ffe77b6..5286d443 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternote.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternote.kt @@ -6,9 +6,11 @@ package com.kuit.afternote.feature.receiver.domain.entity * GET /api/receiver-auth/after-notes 응답의 한 항목에 대응합니다. * * @param id 애프터노트 ID (상세 조회·플레이리스트 라우트 등에서 사용) + * @param title 목록 표시용 제목 (API title 필드) */ data class ReceivedAfternote( val id: Long, + val title: String?, val sourceType: String?, val lastUpdatedAt: String?, val leaveMessage: String? = null diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternoteDetail.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternoteDetail.kt index ac4c125d..86065001 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternoteDetail.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternoteDetail.kt @@ -5,10 +5,18 @@ package com.kuit.afternote.feature.receiver.domain.entity * * GET /api/receiver-auth/after-notes/{afternoteId} 응답에 대응합니다. * 플레이리스트 화면에서는 [playlist]의 [ReceivedAfternotePlaylist.songs]를 사용합니다. + * + * @param senderName 발신자 이름 ("~에 대한 @님의 기록"에서 @) + * @param createdAt 최종 작성일 */ data class ReceivedAfternoteDetail( val id: Long, val category: String? = null, val title: String? = null, + val processMethod: String? = null, + val actions: List = emptyList(), + val leaveMessage: String? = null, + val senderName: String? = null, + val createdAt: String? = null, val playlist: ReceivedAfternotePlaylist? = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternotePlaylist.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternotePlaylist.kt index 73b4a303..29a50bc5 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternotePlaylist.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/domain/entity/ReceivedAfternotePlaylist.kt @@ -4,8 +4,11 @@ package com.kuit.afternote.feature.receiver.domain.entity * 수신한 애프터노트의 플레이리스트 정보 (도메인 모델). * * GET /api/receiver-auth/after-notes/{afternoteId} 응답의 playlist에 대응합니다. + * memorialVideoUrl / memorialThumbnailUrl 은 장례식에 남길 영상 표시용입니다. */ data class ReceivedAfternotePlaylist( val atmosphere: String? = null, - val songs: List = emptyList() + val songs: List = emptyList(), + val memorialVideoUrl: String? = null, + val memorialThumbnailUrl: String? = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverAfternoteDetailRoute.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverAfternoteDetailRoute.kt index 60321a86..e9152310 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverAfternoteDetailRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverAfternoteDetailRoute.kt @@ -1,11 +1,25 @@ package com.kuit.afternote.feature.receiver.presentation.navgraph +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import com.kuit.afternote.app.compositionlocal.DataProviderLocals -import com.kuit.afternote.core.dummy.receiver.AfternoteListItemSeed +import com.kuit.afternote.R +import com.kuit.afternote.core.ui.component.list.AlbumCover import com.kuit.afternote.core.ui.component.navigation.BottomNavItem +import com.kuit.afternote.core.ui.component.navigation.TopBar import com.kuit.afternote.core.ui.screen.afternotedetail.GalleryDetailCallbacks import com.kuit.afternote.core.ui.screen.afternotedetail.GalleryDetailScreen import com.kuit.afternote.core.ui.screen.afternotedetail.GalleryDetailState @@ -15,73 +29,138 @@ import com.kuit.afternote.core.ui.screen.afternotedetail.MemorialGuidelineDetail import com.kuit.afternote.core.ui.screen.afternotedetail.SocialNetworkDetailContent import com.kuit.afternote.core.ui.screen.afternotedetail.SocialNetworkDetailScreen import com.kuit.afternote.core.ui.screen.afternotedetail.rememberAfternoteDetailState +import com.kuit.afternote.feature.receiver.presentation.viewmodel.ReceiverAfternoteDetailViewModel /** * 수신자 애프터노트 상세 라우트. - * ReceiverDataProvider에서 seed를 조회해 카테고리별 상세 스크린(Gallery/MemorialGuideline/Social)을 표시합니다. + * + * GET /api/receiver-auth/after-notes/{afternoteId}로 상세를 조회하고, + * 발신자 상세와 동일한 화면(Social/Gallery/MemorialGuideline)에 title→serviceName, senderName→userName으로 표시합니다. */ @Composable fun ReceiverAfternoteDetailRoute( navHostController: NavHostController, - itemId: String? + itemId: String?, + viewModel: ReceiverAfternoteDetailViewModel = hiltViewModel() ) { - val receiverProvider = DataProviderLocals.LocalReceiverDataProvider.current - val seed = - remember(receiverProvider, itemId) { - receiverProvider - .getAfternoteListSeedsForReceiverList() - .firstOrNull { it.id == itemId } - ?: receiverProvider.getAfternoteListSeedsForReceiverList().firstOrNull() - } - val category = receiverDetailCategoryFromSeed(seed) - val serviceName = seed?.serviceNameLiteral ?: "" - val userName = receiverProvider.getDefaultReceiverTitle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(itemId) { + viewModel.loadDetail(itemId) + } + val defaultState = rememberAfternoteDetailState( defaultBottomNavItem = BottomNavItem.AFTERNOTE ) - when (category) { - ReceiverDetailCategory.GALLERY -> GalleryDetailScreen( - detailState = GalleryDetailState( - serviceName = serviceName.ifEmpty { "갤러리" }, - userName = userName, - finalWriteDate = seed?.date ?: "" - ), - callbacks = GalleryDetailCallbacks( - onBackClick = { navHostController.popBackStack() }, - onEditClick = {} - ), - isEditable = false, - uiState = defaultState - ) - ReceiverDetailCategory.MEMORIAL_GUIDELINE -> MemorialGuidelineDetailScreen( - detailState = MemorialGuidelineDetailState( - userName = userName, - finalWriteDate = seed?.date ?: "" - ), - callbacks = MemorialGuidelineDetailCallbacks( - onBackClick = { navHostController.popBackStack() } - ), - isEditable = false, - uiState = defaultState - ) - ReceiverDetailCategory.SOCIAL -> SocialNetworkDetailScreen( - content = SocialNetworkDetailContent( - serviceName = serviceName, - userName = userName - ), - isEditable = false, - onBackClick = { navHostController.popBackStack() }, - state = defaultState - ) + val onBackClick: () -> Unit = { navHostController.popBackStack(); Unit } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + uiState.errorMessage != null || uiState.detail == null -> { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopBar(title = "", onBackClick = onBackClick) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.errorMessage ?: stringResource(R.string.design_pending) + ) + } + } + } + else -> { + val detail = uiState.detail!! + val serviceName = detail.title.orEmpty().ifEmpty { "애프터노트" } + val userName = detail.senderName.orEmpty().ifEmpty { "" } + val finalWriteDate = detail.createdAt.orEmpty() + + when (receiverDetailCategoryFromApi(detail.category)) { + ReceiverDetailCategory.GALLERY -> GalleryDetailScreen( + detailState = GalleryDetailState( + serviceName = serviceName, + userName = userName, + finalWriteDate = finalWriteDate, + afternoteEditReceivers = emptyList(), + informationProcessingMethod = detail.processMethod.orEmpty(), + processingMethods = detail.actions, + message = detail.leaveMessage.orEmpty() + ), + callbacks = GalleryDetailCallbacks( + onBackClick = onBackClick, + onEditClick = {} + ), + isEditable = false, + uiState = defaultState + ) + ReceiverDetailCategory.MEMORIAL_GUIDELINE -> MemorialGuidelineDetailScreen( + detailState = MemorialGuidelineDetailState( + userName = userName, + finalWriteDate = finalWriteDate, + profileImageUri = null, + albumCovers = detail.playlist?.songs?.mapIndexed { index, s -> + AlbumCover( + id = index.toString(), + imageUrl = s.coverUrl, + title = s.title + ) + } ?: emptyList(), + songCount = detail.playlist?.songs?.size ?: 0, + lastWish = detail.playlist?.atmosphere.orEmpty(), + afternoteEditReceivers = emptyList(), + memorialVideoUrl = detail.playlist?.memorialVideoUrl, + memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl + ), + callbacks = MemorialGuidelineDetailCallbacks( + onBackClick = onBackClick, + onEditClick = {} + ), + isEditable = false, + uiState = defaultState + ) + ReceiverDetailCategory.SOCIAL -> SocialNetworkDetailScreen( + content = SocialNetworkDetailContent( + serviceName = serviceName, + userName = userName, + accountId = "", + password = "", + accountProcessingMethod = detail.processMethod.orEmpty(), + processingMethods = detail.actions, + message = detail.leaveMessage.orEmpty(), + finalWriteDate = finalWriteDate, + afternoteEditReceivers = emptyList() + ), + isEditable = false, + onBackClick = onBackClick, + state = defaultState + ) + } + } } } -private enum class ReceiverDetailCategory { GALLERY, MEMORIAL_GUIDELINE, SOCIAL } +private enum class ReceiverDetailCategory { + GALLERY, + MEMORIAL_GUIDELINE, + SOCIAL +} -private fun receiverDetailCategoryFromSeed(seed: AfternoteListItemSeed?): ReceiverDetailCategory { - return when (seed?.serviceNameLiteral) { - "갤러리" -> ReceiverDetailCategory.GALLERY - "추모 가이드라인" -> ReceiverDetailCategory.MEMORIAL_GUIDELINE +private fun receiverDetailCategoryFromApi(category: String?): ReceiverDetailCategory = + when (category?.uppercase()) { + "GALLERY" -> ReceiverDetailCategory.GALLERY + "PLAYLIST" -> ReceiverDetailCategory.MEMORIAL_GUIDELINE else -> ReceiverDetailCategory.SOCIAL } -} diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt index 91646ea0..c60cca6c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/navgraph/ReceiverMainRoute.kt @@ -22,8 +22,9 @@ import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.feature.receiver.domain.entity.ReceivedTimeLetter import com.kuit.afternote.feature.receiver.presentation.screen.ReceiverAfterNoteScreen -import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverMemorialPlaylistRoute +import com.kuit.afternote.feature.receiver.presentation.screen.afternote.MemorialPlaylistScreen import com.kuit.afternote.feature.receiver.presentation.screen.afternote.ReceiverAfterNoteMainScreen +import com.kuit.afternote.feature.receiver.presentation.viewmodel.ReceiverMemorialPlaylistViewModel import com.kuit.afternote.feature.receiver.presentation.screen.mindrecord.MindRecordDetailScreen import com.kuit.afternote.feature.receiver.presentation.screen.mindrecord.MindRecordScreen import com.kuit.afternote.feature.receiver.presentation.screen.timeletter.ReceiverTimeLetterListScreen @@ -233,16 +234,22 @@ fun ReceiverMainRoute( ) } BottomNavItem.AFTERNOTE -> { + val playlistViewModel: ReceiverMemorialPlaylistViewModel = hiltViewModel() + val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle() val afternoteSenderName = receiverAuthSessionHolder.getSenderName().orEmpty() Log.d(TAG_RECEIVER_MAIN, "AFTERNOTE tab getSenderName=${receiverAuthSessionHolder.getSenderName()}, orEmpty='$afternoteSenderName'") if (showMemorialPlaylist) { - ReceiverMemorialPlaylistRoute( + MemorialPlaylistScreen( + songs = playlistUiState.songs, onBackClick = { showMemorialPlaylist = false } ) } else { ReceiverAfterNoteMainScreen( senderName = afternoteSenderName, albumCovers = albumCovers, + songCount = playlistUiState.songs.size, + memorialVideoUrl = playlistUiState.memorialVideoUrl, + memorialThumbnailUrl = playlistUiState.memorialThumbnailUrl, onNavigateToFullList = { navController.navigate("receiver_afternote_list") }, diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/afternote/ReceiverAfterNoteMainScreen.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/afternote/ReceiverAfterNoteMainScreen.kt index 4475f6c6..d0769f42 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/afternote/ReceiverAfterNoteMainScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/screen/afternote/ReceiverAfterNoteMainScreen.kt @@ -1,7 +1,13 @@ package com.kuit.afternote.feature.receiver.presentation.screen.afternote +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -28,17 +34,26 @@ 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.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.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.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.LastWishesRadioGroup import com.kuit.afternote.core.ui.component.ProfileImage import com.kuit.afternote.core.ui.component.button.ClickButton import com.kuit.afternote.core.ui.component.content.MemorialGuidelineContent import com.kuit.afternote.core.ui.component.content.MemorialGuidelineSlots +import com.kuit.afternote.core.ui.component.detail.InfoCard import com.kuit.afternote.core.ui.component.list.AlbumCover import com.kuit.afternote.core.ui.component.list.MemorialPlaylist import com.kuit.afternote.core.ui.component.navigation.BottomNavItem @@ -59,6 +74,8 @@ fun ReceiverAfterNoteMainScreen( profileImageResId: Int? = null, albumCovers: List, songCount: Int = 16, + memorialVideoUrl: String? = null, + memorialThumbnailUrl: String? = null, showBottomBar: Boolean = true ) { Log.d(TAG_RECEIVER_AFTERNOTE_MAIN, "ReceiverAfterNoteMainScreen received senderName='$senderName'") @@ -130,7 +147,10 @@ fun ReceiverAfterNoteMainScreen( ) }, videoContent = { - ReceiverVideoSection() + ReceiverVideoSection( + memorialVideoUrl = memorialVideoUrl, + memorialThumbnailUrl = memorialThumbnailUrl + ) } ), sectionSpacing = 32.dp @@ -154,28 +174,105 @@ private const val TAG_RECEIVER_AFTERNOTE_MAIN = "ReceiverAfterNoteMain" private const val LABEL_VIDEO_SECTION = "장례식에 남길 영상" @Composable -private fun ReceiverVideoSection() { +private fun ReceiverVideoSection( + memorialVideoUrl: String? = null, + memorialThumbnailUrl: String? = null +) { + val context = LocalContext.current Column(modifier = Modifier.fillMaxWidth()) { ReceiverSectionHeader() Spacer(modifier = Modifier.height(12.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color.LightGray), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", - tint = Color.White, + if (!memorialVideoUrl.isNullOrBlank()) { + val videoUrl = memorialVideoUrl + InfoCard( modifier = Modifier - .size(48.dp) - .background(Color.Black.copy(alpha = 0.3f), CircleShape) - .padding(8.dp) + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(videoUrl)) + if (context.packageManager.resolveActivity( + intent, + PackageManager.MATCH_DEFAULT_ONLY + ) != null + ) { + context.startActivity(intent) + } else { + Toast + .makeText( + context, + context.getString(R.string.receiver_memorial_video_no_app), + Toast.LENGTH_SHORT + ) + .show() + } + } + ) { + ReceiverMemorialVideoThumbnail(thumbnailUrl = memorialThumbnailUrl) + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.LightGray), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + .padding(8.dp) + ) + } + } + } +} + +@Composable +private fun ReceiverMemorialVideoThumbnail(thumbnailUrl: String?) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(183.dp) + .clip(RoundedCornerShape(16.dp)) + ) { + if (!thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUrl) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = "장례식에 남길 영상 썸네일", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color(0x99757575), + Color(0x99222222) + ) + ) + ) + ) + Image( + painter = painterResource(R.drawable.ic_playback), + contentDescription = "영상 재생", + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceivedListScreenUiState.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceivedListScreenUiState.kt index 08018b6d..4d548cc4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceivedListScreenUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceivedListScreenUiState.kt @@ -28,9 +28,11 @@ data class ReceiverTimeLettersListUiState( * 수신 애프터노트 목록 화면(설정 플로우)용 리스트 아이템 UI 모델. * * @param id 애프터노트 ID (상세·플레이리스트 라우트 이동 시 사용) + * @param title 목록 표시용 제목 (API title) */ data class ReceivedAfternoteListItemUi( val id: Long, + val title: String, val sourceType: String, val lastUpdatedAt: String ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceiverMemorialPlaylistUiState.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceiverMemorialPlaylistUiState.kt index 31058c64..85bac3cb 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceiverMemorialPlaylistUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/uimodel/ReceiverMemorialPlaylistUiState.kt @@ -5,10 +5,13 @@ import com.kuit.afternote.core.uimodel.PlaylistSongDisplay /** * 수신자 추모 플레이리스트 화면 UI 상태. * - * GET /api/receiver-auth/after-notes/{afternoteId}의 playlist.songs를 표시합니다. + * GET /api/receiver-auth/after-notes/{afternoteId}의 playlist.songs 및 + * playlist.memorialVideo(장례식에 남길 영상)를 표시합니다. */ data class ReceiverMemorialPlaylistUiState( val songs: List = emptyList(), + val memorialVideoUrl: String? = null, + val memorialThumbnailUrl: String? = null, val isLoading: Boolean = false, val errorMessage: String? = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternoteDetailViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternoteDetailViewModel.kt new file mode 100644 index 00000000..a33be75f --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternoteDetailViewModel.kt @@ -0,0 +1,90 @@ +package com.kuit.afternote.feature.receiver.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kuit.afternote.feature.receiver.domain.entity.ReceivedAfternoteDetail +import com.kuit.afternote.feature.receiver.domain.usecase.GetAfternoteDetailByAuthCodeUseCase +import com.kuit.afternote.feature.receiverauth.session.ReceiverAuthSessionHolder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 수신자 애프터노트 상세 화면 ViewModel. + * + * GET /api/receiver-auth/after-notes/{afternoteId} (X-Auth-Code)로 상세를 조회합니다. + */ +@HiltViewModel +class ReceiverAfternoteDetailViewModel + @Inject + constructor( + private val receiverAuthSessionHolder: ReceiverAuthSessionHolder, + private val getAfternoteDetailByAuthCodeUseCase: GetAfternoteDetailByAuthCodeUseCase + ) : ViewModel() { + + private val _uiState = MutableStateFlow(ReceiverAfternoteDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadDetail(itemId: String?) { + if (itemId.isNullOrBlank()) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "잘못된 항목 ID입니다." + ) + } + return + } + val afternoteId = itemId.toLongOrNull() + if (afternoteId == null) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "잘못된 항목 ID입니다." + ) + } + return + } + val authCode = receiverAuthSessionHolder.getAuthCode() + if (authCode.isNullOrBlank()) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "인증 정보가 없습니다." + ) + } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + getAfternoteDetailByAuthCodeUseCase(authCode, afternoteId) + .onSuccess { detail -> + _uiState.update { + it.copy( + detail = detail, + isLoading = false, + errorMessage = null + ) + } + } + .onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "상세 정보를 불러오지 못했습니다." + ) + } + } + } + } +} + +data class ReceiverAfternoteDetailUiState( + val detail: ReceivedAfternoteDetail? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null +) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternotesListViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternotesListViewModel.kt index 198630c3..55629ba8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternotesListViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverAfternotesListViewModel.kt @@ -44,6 +44,7 @@ class ReceiverAfternotesListViewModel items = data.items.map { item -> ReceivedAfternoteListItemUi( id = item.id, + title = item.title.orEmpty(), sourceType = item.sourceType.orEmpty(), lastUpdatedAt = item.lastUpdatedAt.orEmpty() ) @@ -67,4 +68,12 @@ class ReceiverAfternotesListViewModel fun clearError() { _uiState.update { it.copy(errorMessage = null) } } + + /** + * 에러 상태에서 재시도. 세션의 authCode로 목록을 다시 로드합니다. + */ + fun retry() { + clearError() + receiverAuthSessionHolder.getAuthCode()?.let { loadAfterNotes(it) } + } } diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt index 33ed31e7..b48ce2fb 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt @@ -89,7 +89,8 @@ class ReceiverMemorialPlaylistViewModel afternoteId = afternoteId ) .onSuccess { detail -> - val songs = detail.playlist?.songs?.mapIndexed { index, song -> + val playlist = detail.playlist + val songs = playlist?.songs?.mapIndexed { index, song -> PlaylistSongDisplay( id = index.toString(), title = song.title, @@ -100,6 +101,8 @@ class ReceiverMemorialPlaylistViewModel _uiState.update { it.copy( songs = songs, + memorialVideoUrl = playlist?.memorialVideoUrl, + memorialThumbnailUrl = playlist?.memorialThumbnailUrl, isLoading = false, errorMessage = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/mapper/ReceiverAuthContentMapper.kt b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/mapper/ReceiverAuthContentMapper.kt index 5bbfe1b6..a7aabb42 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/mapper/ReceiverAuthContentMapper.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiverauth/data/mapper/ReceiverAuthContentMapper.kt @@ -67,6 +67,7 @@ fun ReceivedMindRecordAuthResponseDto.toReceivedMindRecord(): ReceivedMindRecord fun ReceivedAfternoteAuthResponseDto.toReceivedAfternote(): ReceivedAfternote = ReceivedAfternote( id = id, + title = title.orEmpty(), sourceType = category.orEmpty(), lastUpdatedAt = createdAt.orEmpty(), leaveMessage = leaveMessage @@ -84,10 +85,17 @@ fun ReceivedAfternoteDetailAuthResponseDto.toReceivedAfternoteDetail(): Received id = id, category = category, title = title, + processMethod = processMethod, + actions = actions, + leaveMessage = leaveMessage, + senderName = senderName, + createdAt = createdAt, playlist = playlist?.let { p -> ReceivedAfternotePlaylist( atmosphere = p.atmosphere, - songs = p.songs.map { it.toReceivedPlaylistSong() } + songs = p.songs.map { it.toReceivedPlaylistSong() }, + memorialVideoUrl = p.memorialVideo?.videoUrl, + memorialThumbnailUrl = p.memorialVideo?.thumbnailUrl ) } ) diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt index 821f656a..c54883fa 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt @@ -21,6 +21,7 @@ import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.ConfirmationPopup import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.util.getAfternoteDisplayRes +import com.kuit.afternote.core.ui.util.getIconResForServiceName import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver import com.kuit.afternote.feature.onboarding.presentation.navgraph.OnboardingRoute @@ -469,11 +470,12 @@ private fun ReceiverAfternoteListRouteContent( val afterNotesViewModel: ReceiverAfternotesListViewModel = hiltViewModel() val afterNotesState by afterNotesViewModel.uiState.collectAsStateWithLifecycle() val afternoteItems = afterNotesState.items.map { item -> - val (stringResId, iconResId) = getAfternoteDisplayRes(item.sourceType) - val serviceName = stringResource(stringResId) + val iconResId = + if (item.title.isNotBlank()) getIconResForServiceName(item.title) + else getAfternoteDisplayRes(item.sourceType).second AfternoteListDisplayItem( id = item.id.toString(), - serviceName = serviceName, + serviceName = item.title, date = item.lastUpdatedAt, iconResId = iconResId ) diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt index 1d156c1b..17388020 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/postdelivery/PostDeliveryConditionScreen.kt @@ -235,7 +235,9 @@ fun PostDeliveryConditionScreen( Spacer(modifier = Modifier.height(16.dp)) ClickButton( color = B3, - onButtonClick = { viewModel.onSaveLastGreeting() }, + onButtonClick = { + viewModel.onSaveLastGreeting(lastGreetingTextFieldState.text.toString()) + }, title = stringResource(R.string.post_delivery_confirm), modifier = Modifier.padding(horizontal = 20.dp) ) @@ -354,7 +356,7 @@ private class FakePostDeliveryConditionViewModel : PostDeliveryConditionViewMode // No-op: Fake for Preview only; no persistence. } - override fun onSaveLastGreeting() { + override fun onSaveLastGreeting(lastGreetingText: String) { // No-op: Fake for Preview only; no persistence. } } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt index 6b4e9fe5..2c9c9014 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/PostDeliveryConditionViewModel.kt @@ -43,7 +43,7 @@ interface PostDeliveryConditionViewModelContract { fun onTriggerConditionSelected(option: TriggerConditionOption) fun onDateSelected(date: LocalDate?) fun onLastGreetingChanged(text: String) - fun onSaveLastGreeting() + fun onSaveLastGreeting(lastGreetingText: String) } /** @@ -122,7 +122,8 @@ constructor( _state.update { it.copy(lastGreetingMessage = text) } } - override fun onSaveLastGreeting() { + override fun onSaveLastGreeting(lastGreetingText: String) { + _state.update { it.copy(lastGreetingMessage = lastGreetingText) } viewModelScope.launch { updateDeliveryConditionApi() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bb2da24..72e727b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ 갤러리 추모 가이드라인 네이버 메일 + 영상을 재생할 수 있는 앱이 없습니다. 故 %1$s님이 남기신 기록 %1$d개의 애프터노트가 있습니다. @@ -138,4 +139,5 @@ 마지막 인사말을 입력해주세요 확인 + 재시도