From 0de8da81072565497c33d2daeda61195bdcb0b98 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Fri, 29 May 2026 23:24:21 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0=20API=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReceiverTimeLetterRepository, ReceiverTimeLetterApiService, DTO, Mapper, RepositoryImpl 추가. receiver-auth/time-letters 목록/상세 조회 엔드포인트 연결. Co-Authored-By: Claude Sonnet 4.6 --- .../detail/MemorialReceivedDetailScreen.kt | 313 ------------------ .../data/api/ReceiverTimeLetterApiService.kt | 17 + .../timeletter/data/di/TimeLetterModule.kt | 14 + .../data/dto/ReceivedTimeLetterDto.kt | 24 ++ .../data/mapper/ReceivedTimeLetterMapper.kt | 26 ++ .../ReceiverTimeLetterRepositoryImpl.kt | 27 ++ .../domain/model/ReceivedTimeLetterModel.kt | 22 ++ .../ReceiverTimeLetterRepository.kt | 10 + 8 files changed, 140 insertions(+), 313 deletions(-) delete mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt create mode 100644 feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/ReceiverTimeLetterApiService.kt create mode 100644 feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt create mode 100644 feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt create mode 100644 feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/ReceiverTimeLetterRepositoryImpl.kt create mode 100644 feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/ReceivedTimeLetterModel.kt create mode 100644 feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/ReceiverTimeLetterRepository.kt diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt deleted file mode 100644 index 50affdec9..000000000 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.afternote.feature.afternote.presentation.receiver.detail - -import android.content.Intent -import android.content.pm.PackageManager -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 -import androidx.compose.foundation.layout.Spacer -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.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.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.core.net.toUri -import coil3.compose.AsyncImage -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders -import coil3.request.ImageRequest -import com.afternote.core.model.AlbumCover -import com.afternote.core.ui.ProfileImage -import com.afternote.core.ui.bottombar.BottomBar -import com.afternote.core.ui.bottombar.BottomNavTab -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.shared.LastWishesRadioGroup -import com.afternote.feature.afternote.presentation.shared.MemorialGuidelineContent -import com.afternote.feature.afternote.presentation.shared.detail.InfoCard -import com.afternote.feature.afternote.presentation.shared.detail.song.MemorialPlaylist - -/** - * MEMORIAL 카테고리의 수신자 측 detail prototype. - * - * 현재 [ReceivedAfternoteDetailSuccessMapper] 의 MEMORIAL 분기는 디자이너 보류라 - * [com.afternote.feature.afternote.presentation.author.navigation.DesignPendingDetailContent] 로 폴백 - * 한다. 디자인 확정 시 mapper 에 MEMORIAL UI 모델을 추가하고 [ReceivedAfternoteDetailRoute] 의 - * when 분기에서 본 화면을 호출하도록 wire-up 한다. - * - * 페어 sub-screen: [com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen] - * (추모 플레이리스트 진입). - */ -@Composable -fun MemorialReceivedDetailScreen( - senderName: String, - onNavigateToFullList: () -> Unit = {}, - onNavigateToPlaylist: () -> Unit = {}, - onBackClick: () -> Unit = {}, - profileImageResId: Int? = null, - albumCovers: List, - songCount: Int = 16, - memorialVideoUrl: String? = null, - memorialThumbnailUrl: String? = null, - showBottomBar: Boolean = true, -) { - var selectedBottomNavItem by remember { mutableStateOf(BottomNavTab.TIMELETTER) } - profileImageResId ?: R.drawable.feature_afternote_img_default_profile_deceased - - Scaffold( - containerColor = Color.Transparent, - topBar = { - Column(modifier = Modifier.statusBarsPadding()) { - DetailTopBar( - title = "故${senderName}님의 애프터노트", - onBackClick = { onBackClick() }, - ) - } - }, - bottomBar = { - if (showBottomBar) { - BottomBar( - selectedNavTab = selectedBottomNavItem, - onTabClick = { selectedBottomNavItem = it }, - ) - } - }, - ) { innerPadding -> - LazyColumn( - modifier = - Modifier - .padding(innerPadding) - .padding(20.dp) - .fillMaxSize(), - contentPadding = PaddingValues(vertical = 20.dp), - ) { - item { - MemorialGuidelineContent( - introContent = { - Text( - text = "故 ${senderName}님의 애프터노트입니다.", - style = - AfternoteDesign.typography.textField.copy( - fontWeight = FontWeight.Medium, - color = AfternoteDesign.colors.gray9, - ), - modifier = Modifier.fillMaxWidth(), - ) - }, - photoContent = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ProfileImage() - } - }, - playlistContent = { - MemorialPlaylist( - label = "추모 플레이리스트", - songCount = songCount, - albumCovers = albumCovers, - onAddSongClick = null, - onPlaylistClick = onNavigateToPlaylist, - ) - }, - lastWishContent = { - LastWishesRadioGroup( - displayTextOnly = "끼니 거르지 말고 건강 챙기고 지내.", - ) - }, - sectionSpacing = 32.dp, - videoContent = { - ReceiverVideoSection( - memorialVideoUrl = memorialVideoUrl, - memorialThumbnailUrl = memorialThumbnailUrl, - ) - }, - ) - } - item { - Spacer(modifier = Modifier.height(70.dp)) - - AfternoteButton( - text = "애프터노트 확인하기", - onClick = onNavigateToFullList, - type = AfternoteButtonType.Default, - ) - Spacer(modifier = Modifier.height(20.dp)) - } - } - } -} - -private const val LABEL_VIDEO_SECTION = "장례식에 남길 영상" - -@Composable -private fun ReceiverVideoSection( - memorialVideoUrl: String? = null, - memorialThumbnailUrl: String? = null, -) { - val context = LocalContext.current - Column(modifier = Modifier.fillMaxWidth()) { - ReceiverSectionHeader() - Spacer(modifier = Modifier.height(12.dp)) - if (!memorialVideoUrl.isNullOrBlank()) { - InfoCard( - modifier = - Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, memorialVideoUrl.toUri()) - if (context.packageManager.resolveActivity( - intent, - PackageManager.MATCH_DEFAULT_ONLY, - ) != null - ) { - context.startActivity(intent) - } else { - Toast - .makeText( - context, - "영상을 재생할 수 있는 앱이 없습니다.", - Toast.LENGTH_SHORT, - ).show() - } - }, - ) { - ReceiverMemorialVideoThumbnail(thumbnailUrl = memorialThumbnailUrl) - } - } else { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(12.dp)) - .background(AfternoteDesign.colors.gray3), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", - tint = AfternoteDesign.colors.white, - modifier = - Modifier - .size(48.dp) - .background( - AfternoteDesign.colors.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()) { - val ctx = LocalContext.current - val imageRequest = - remember(thumbnailUrl) { - ImageRequest - .Builder(ctx) - .data(thumbnailUrl) - .httpHeaders( - NetworkHeaders - .Builder() - .set("User-Agent", "Afternote Android App") - .build(), - ).build() - } - AsyncImage( - model = imageRequest, - contentDescription = "장례식에 남길 영상 썸네일", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - Box( - modifier = - Modifier - .fillMaxSize() - .background( - brush = - Brush.verticalGradient( - colors = - listOf( - AfternoteDesign.colors.gray6.copy(alpha = 153f / 255f), - AfternoteDesign.colors.gray9.copy(alpha = 153f / 255f), - ), - ), - ), - ) - Image( - painter = painterResource(R.drawable.feature_afternote_ic_playback), - contentDescription = "영상 재생", - modifier = - Modifier - .align(Alignment.Center) - .size(32.dp), - ) - } -} - -@Composable -private fun ReceiverSectionHeader(title: String = LABEL_VIDEO_SECTION) { - Text( - text = title, - style = - AfternoteDesign.typography.textField.copy( - fontWeight = FontWeight.Medium, - color = AfternoteDesign.colors.gray9, - ), - modifier = Modifier.padding(bottom = 8.dp), - ) -} - -@Preview(showBackground = true) -@Composable -private fun PreviewMemorialReceivedDetail() { - AfternoteTheme { - MemorialReceivedDetailScreen(senderName = "박서연", albumCovers = emptyList()) - } -} diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/ReceiverTimeLetterApiService.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/ReceiverTimeLetterApiService.kt new file mode 100644 index 000000000..0a71ca6f4 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/ReceiverTimeLetterApiService.kt @@ -0,0 +1,17 @@ +package com.afternote.feature.timeletter.data.api + +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.timeletter.data.dto.ReceivedTimeLetterListResponseDto +import com.afternote.feature.timeletter.data.dto.ReceivedTimeLetterResponseDto +import retrofit2.http.GET +import retrofit2.http.Path + +interface ReceiverTimeLetterApiService { + @GET("receiver-auth/time-letters") + suspend fun getReceivedTimeLetters(): BaseResponse + + @GET("receiver-auth/time-letters/{timeLetterReceiverId}") + suspend fun getReceivedTimeLetterDetail( + @Path("timeLetterReceiverId") timeLetterReceiverId: Long, + ): BaseResponse +} diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt index 77d433631..cc7e86907 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt @@ -1,10 +1,13 @@ package com.afternote.feature.timeletter.data.di import android.content.Context +import com.afternote.feature.timeletter.data.api.ReceiverTimeLetterApiService import com.afternote.feature.timeletter.data.api.TimeLetterApiService import com.afternote.feature.timeletter.data.repositoryImpl.FileMetadataRepositoryImpl +import com.afternote.feature.timeletter.data.repositoryImpl.ReceiverTimeLetterRepositoryImpl import com.afternote.feature.timeletter.data.repositoryImpl.TimeLetterRepositoryImpl import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository import dagger.Module import dagger.Provides @@ -31,4 +34,15 @@ object TimeLetterModule { fun provideFileMetadataRepository( @ApplicationContext context: Context, ): FileMetadataRepository = FileMetadataRepositoryImpl(context) + + @Provides + @Singleton + fun provideReceiverTimeLetterApiService(retrofit: Retrofit): ReceiverTimeLetterApiService = + retrofit.create(ReceiverTimeLetterApiService::class.java) + + @Provides + @Singleton + fun provideReceiverTimeLetterRepository( + receiverTimeLetterApiService: ReceiverTimeLetterApiService, + ): ReceiverTimeLetterRepository = ReceiverTimeLetterRepositoryImpl(receiverTimeLetterApiService) } diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt new file mode 100644 index 000000000..4276f32d8 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt @@ -0,0 +1,24 @@ +package com.afternote.feature.timeletter.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReceivedTimeLetterResponseDto( + @SerialName("id") val id: Long, + @SerialName("timeLetterReceiverId") val timeLetterReceiverId: Long, + @SerialName("title") val title: String? = null, + @SerialName("blocks") val blocks: List = emptyList(), + @SerialName("sendAt") val sendAt: String? = null, + @SerialName("status") val status: TimeLetterStatusDto, + @SerialName("senderName") val senderName: String? = null, + @SerialName("deliveredAt") val deliveredAt: String? = null, + @SerialName("createdAt") val createdAt: String? = null, + @SerialName("isRead") val isRead: Boolean, +) + +@Serializable +data class ReceivedTimeLetterListResponseDto( + @SerialName("timeLetters") val timeLetters: List, + @SerialName("totalCount") val totalCount: Int, +) diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt new file mode 100644 index 000000000..38e6f6061 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt @@ -0,0 +1,26 @@ +package com.afternote.feature.timeletter.data.mapper + +import com.afternote.feature.timeletter.data.dto.ReceivedTimeLetterListResponseDto +import com.afternote.feature.timeletter.data.dto.ReceivedTimeLetterResponseDto +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList + +fun ReceivedTimeLetterResponseDto.toDomain(): ReceivedTimeLetter = + ReceivedTimeLetter( + id = id, + timeLetterReceiverId = timeLetterReceiverId, + title = title, + blocks = blocks.map { it.toDomain() }, + sendAt = sendAt, + status = status.toDomain(), + senderName = senderName, + deliveredAt = deliveredAt, + createdAt = createdAt, + isRead = isRead, + ) + +fun ReceivedTimeLetterListResponseDto.toDomain(): ReceivedTimeLetterList = + ReceivedTimeLetterList( + timeLetters = timeLetters.map { it.toDomain() }, + totalCount = totalCount, + ) diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/ReceiverTimeLetterRepositoryImpl.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/ReceiverTimeLetterRepositoryImpl.kt new file mode 100644 index 000000000..44e4343d4 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/ReceiverTimeLetterRepositoryImpl.kt @@ -0,0 +1,27 @@ +package com.afternote.feature.timeletter.data.repositoryImpl + +import com.afternote.core.network.model.requireData +import com.afternote.feature.timeletter.data.api.ReceiverTimeLetterApiService +import com.afternote.feature.timeletter.data.mapper.toDomain +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository +import javax.inject.Inject + +class ReceiverTimeLetterRepositoryImpl + @Inject + constructor( + private val receiverTimeLetterApiService: ReceiverTimeLetterApiService, + ) : ReceiverTimeLetterRepository { + override suspend fun getReceivedTimeLetters(): ReceivedTimeLetterList = + receiverTimeLetterApiService + .getReceivedTimeLetters() + .requireData() + .toDomain() + + override suspend fun getReceivedTimeLetterDetail(timeLetterReceiverId: Long): ReceivedTimeLetter = + receiverTimeLetterApiService + .getReceivedTimeLetterDetail(timeLetterReceiverId) + .requireData() + .toDomain() + } diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/ReceivedTimeLetterModel.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/ReceivedTimeLetterModel.kt new file mode 100644 index 000000000..354ff66ee --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/ReceivedTimeLetterModel.kt @@ -0,0 +1,22 @@ +package com.afternote.feature.timeletter.domain.model + +data class ReceivedTimeLetter( + val id: Long, + val timeLetterReceiverId: Long, + val title: String?, + val blocks: List, + val sendAt: String?, + val status: TimeLetterStatus, + val senderName: String?, + val deliveredAt: String?, + val createdAt: String?, + val isRead: Boolean, +) { + val content: String? + get() = blocks.firstOrNull { it.blockType == TimeLetterBlockType.TEXT }?.textContent +} + +data class ReceivedTimeLetterList( + val timeLetters: List, + val totalCount: Int, +) diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/ReceiverTimeLetterRepository.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/ReceiverTimeLetterRepository.kt new file mode 100644 index 000000000..c27de6121 --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/ReceiverTimeLetterRepository.kt @@ -0,0 +1,10 @@ +package com.afternote.feature.timeletter.domain.repository + +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList + +interface ReceiverTimeLetterRepository { + suspend fun getReceivedTimeLetters(): ReceivedTimeLetterList + + suspend fun getReceivedTimeLetterDetail(timeLetterReceiverId: Long): ReceivedTimeLetter +} From 64da4c659de8432e664385f65ccb7bf164f9671c Mon Sep 17 00:00:00 2001 From: kyungmin Date: Fri, 29 May 2026 23:40:41 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecipientTimeLetterDetailViewModel, RecipientTimeLetterDetailScreen 구현. sender 화면과 동일한 레이아웃, 발신인 표시로 대체. TimeLetterRecipientDetailRoute 추가 (navGraph 등록은 진입 경로 확정 후 진행). Co-Authored-By: Claude Sonnet 4.6 --- .../navigation/TimeLetterRoute.kt | 5 + .../RecipientTimeLetterDetailScreen.kt | 236 +++++++++++++++--- .../RecipientTimeLetterDetailViewModel.kt | 55 ++++ 3 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeLetterDetailViewModel.kt diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterRoute.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterRoute.kt index 701cdb3e0..7c6491070 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterRoute.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterRoute.kt @@ -22,4 +22,9 @@ sealed interface TimeLetterRoute { @Serializable data object TimeLetterRecipientFilterRoute : TimeLetterRoute + + @Serializable + data class TimeLetterRecipientDetailRoute( + val timeLetterReceiverId: Long, + ) : TimeLetterRoute } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt index bfe6c01dc..f13d72a9f 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt @@ -4,85 +4,249 @@ import androidx.compose.foundation.Image 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.Row import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +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 coil3.compose.AsyncImage import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterBlock +import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.presentation.R +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeLetterDetailUiState +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeLetterDetailViewModel @Composable fun RecipientTimeLetterDetailScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, + viewModel: RecipientTimeLetterDetailViewModel = hiltViewModel(), ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = modifier, topBar = { DetailTopBar(title = "타임레터", onBackClick = onBackClick) }, ) { innerPadding -> + when (val state = uiState) { + is RecipientTimeLetterDetailUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } - LazyColumn(contentPadding = innerPadding) { - item { + is RecipientTimeLetterDetailUiState.Error -> { Box( - modifier = - Modifier - .fillMaxWidth() - .height(160.dp), + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, ) { - // 배경 이미지 + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "타임레터를 불러올 수 없습니다.", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.load() }) { + Text("다시 시도") + } + } + } + } + + is RecipientTimeLetterDetailUiState.Success -> { + RecipientTimeLetterDetailContent( + letter = state.letter, + contentPadding = innerPadding, + ) + } + } + } +} + +@Composable +private fun RecipientTimeLetterDetailContent( + letter: ReceivedTimeLetter, + contentPadding: PaddingValues, +) { + val sendAtText = letter.sendAt?.take(10)?.replace("-", ".") ?: "" + + val heroImageUrl = + remember(letter.blocks) { + letter.blocks.firstOrNull { it.blockType == TimeLetterBlockType.IMAGE }?.url + } + + LazyColumn(contentPadding = contentPadding) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(160.dp), + ) { + if (heroImageUrl != null) { + AsyncImage( + model = heroImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } else { Image( painter = painterResource(R.drawable.ex_box_img), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), ) - - // 텍스트 영역 - Column( - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(16.dp), + } + Column( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + ) { + Text( + text = letter.title ?: "제목 없음", + style = AfternoteDesign.typography.h2, + color = AfternoteDesign.colors.white, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "채연아 20번째 생일을 축하해", - style = AfternoteDesign.typography.h2, + text = "발신인 ${letter.senderName ?: ""}", + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.white, + ) + Text( + text = "발송 예정일 $sendAtText", + style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.white, ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "수신인 박채연", - style = AfternoteDesign.typography.footnoteCaption, - color = AfternoteDesign.colors.white, - ) - Text( - text = "발송 예정일 2027.11.24.", - style = AfternoteDesign.typography.footnoteCaption, - color = AfternoteDesign.colors.white, - ) - } } } } - item { - Column { - // Todo: 블록형으로 넣어야겟다. - } + } + + items(letter.blocks.sortedBy { it.blockOrder }) { block -> + RecipientTimeLetterBlockView(block = block) + } + } +} + +@Composable +private fun RecipientTimeLetterBlockView(block: TimeLetterBlock) { + when (block.blockType) { + TimeLetterBlockType.TEXT -> { + Text( + text = block.textContent ?: "", + style = AfternoteDesign.typography.bodySmallR, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + + TimeLetterBlockType.IMAGE -> { + AsyncImage( + model = block.url, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) + } + + TimeLetterBlockType.AUDIO -> { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { + Text( + text = "🎵 ${block.url?.substringAfterLast('/') ?: "오디오"}", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Text( + text = "재생 기능 준비 중", + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray4, + ) } } + + TimeLetterBlockType.FILE -> { + Text( + text = "📎 ${block.url?.substringAfterLast('/') ?: "파일"}", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + } + + TimeLetterBlockType.LINK -> { + Text( + text = "🔗 ${block.url ?: ""}", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + } } } + +@Preview +@Composable +private fun RecipientTimeLetterDetailScreenPrev() { + RecipientTimeLetterDetailContent( + letter = + ReceivedTimeLetter( + id = 1L, + timeLetterReceiverId = 1L, + title = "채연아 20번째 생일을 축하해", + sendAt = "2027-11-24T00:00:00", + status = TimeLetterStatus.SENT, + senderName = "박경민", + deliveredAt = "2027-11-24T00:00:00", + createdAt = "2026-01-01T00:00:00", + isRead = false, + blocks = + listOf( + TimeLetterBlock( + id = 1L, + blockType = TimeLetterBlockType.TEXT, + blockOrder = 1, + textContent = "생일 축하해, 앞으로도 잘 부탁해.", + url = null, + mimeType = null, + ), + ), + ), + contentPadding = PaddingValues(0.dp), + ) +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeLetterDetailViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeLetterDetailViewModel.kt new file mode 100644 index 000000000..62ced130b --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeLetterDetailViewModel.kt @@ -0,0 +1,55 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository +import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface RecipientTimeLetterDetailUiState { + data object Loading : RecipientTimeLetterDetailUiState + + data class Success( + val letter: ReceivedTimeLetter, + ) : RecipientTimeLetterDetailUiState + + data object Error : RecipientTimeLetterDetailUiState +} + +@HiltViewModel +class RecipientTimeLetterDetailViewModel + @Inject + constructor( + private val receiverTimeLetterRepository: ReceiverTimeLetterRepository, + savedStateHandle: SavedStateHandle, + ) : ViewModel() { + private val timeLetterReceiverId: Long = + savedStateHandle.toRoute().timeLetterReceiverId + + private val _uiState = MutableStateFlow(RecipientTimeLetterDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _uiState.value = RecipientTimeLetterDetailUiState.Loading + runCatching { receiverTimeLetterRepository.getReceivedTimeLetterDetail(timeLetterReceiverId) } + .onSuccess { letter -> + _uiState.value = RecipientTimeLetterDetailUiState.Success(letter = letter) + }.onFailure { + _uiState.value = RecipientTimeLetterDetailUiState.Error + } + } + } + } From 2a2b649d87ad9fd3be264798d7fd2bea549cc7c3 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 30 May 2026 00:50:51 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20CalendarG?= =?UTF-8?q?ridContent=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecipientTimeletterScreen, RecipientTimeletterViewModel 추가. CalendarGridContent를 core:ui에 public composable로 분리하여 DatePickerContent 하위 호환 유지하면서 그리드만 단독 사용 가능하도록 개선. Co-Authored-By: Claude Sonnet 4.6 --- .../core/ui/calendar/BottomSheetCalendar.kt | 84 +++--- .../RecipientTimeLetterDetailScreen.kt | 2 +- .../recipient/RecipientTimeletterScreen.kt | 241 ++++++++++++++++++ .../viewmodel/RecipientTimeletterViewModel.kt | 48 ++++ 4 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt create mode 100644 feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeletterViewModel.kt diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt index f413e2262..8d002668d 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt @@ -196,44 +196,62 @@ fun DatePickerContent( Spacer(modifier = Modifier.height(16.dp)) - val dayLabels = listOf("일", "월", "화", "수", "목", "금", "토") - Row(modifier = Modifier.fillMaxWidth()) { - dayLabels.forEach { label -> - Text( - text = label, - modifier = Modifier.weight(1f), - color = Color(0xFF000000).copy(alpha = 0.3f), - style = AfternoteDesign.typography.footnoteCaption, - textAlign = TextAlign.Center, - ) - } - } + CalendarGridContent( + currentYear = currentYear, + currentMonth = currentMonth, + selectedDate = selectedDate, + onDateSelect = onDateSelect, + ) + } + Spacer(modifier = Modifier.height(50.dp)) + } + } +} - Spacer(modifier = Modifier.height(10.dp)) +@Composable +fun CalendarGridContent( + currentYear: Int, + currentMonth: Int, + selectedDate: LocalDate, + onDateSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + val dayLabels = listOf("일", "월", "화", "수", "목", "금", "토") + Row(modifier = Modifier.fillMaxWidth()) { + dayLabels.forEach { label -> + Text( + text = label, + modifier = Modifier.weight(1f), + color = Color(0xFF000000).copy(alpha = 0.3f), + style = AfternoteDesign.typography.footnoteCaption, + textAlign = TextAlign.Center, + ) + } + } - val days = - buildPickerDays( - year = currentYear, - month = currentMonth, - selectedDate = selectedDate, - ) - days.chunked(7).forEach { week -> - Row(modifier = Modifier.fillMaxWidth()) { - week.forEach { dayModel -> - Box(modifier = Modifier.weight(1f)) { - PickerDayCell( - model = dayModel, - onSelect = { dayModel.day?.let(onDateSelect) }, - ) - } - } - repeat(7 - week.size) { - Spacer(modifier = Modifier.weight(1f)) - } + Spacer(modifier = Modifier.height(10.dp)) + + val days = + buildPickerDays( + year = currentYear, + month = currentMonth, + selectedDate = selectedDate, + ) + days.chunked(7).forEach { week -> + Row(modifier = Modifier.fillMaxWidth()) { + week.forEach { dayModel -> + Box(modifier = Modifier.weight(1f)) { + PickerDayCell( + model = dayModel, + onSelect = { dayModel.day?.let(onDateSelect) }, + ) } } + repeat(7 - week.size) { + Spacer(modifier = Modifier.weight(1f)) + } } - Spacer(modifier = Modifier.height(50.dp)) } } } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt index f13d72a9f..c7b1ccb52 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeLetterDetailScreen.kt @@ -220,7 +220,7 @@ private fun RecipientTimeLetterBlockView(block: TimeLetterBlock) { } } -@Preview +@Preview(showBackground = true) @Composable private fun RecipientTimeLetterDetailScreenPrev() { RecipientTimeLetterDetailContent( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt new file mode 100644 index 000000000..5d66897df --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -0,0 +1,241 @@ +package com.afternote.feature.timeletter.presentation.screen.recipient + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.calendar.CalendarGridContent +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.HomeTopBar +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeletterUiState +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeletterViewModel +import java.time.LocalDate + +@Composable +fun RecipientTimeletterScreen( + onLetterClick: (Long) -> Unit = {}, + modifier: Modifier = Modifier, + viewModel: RecipientTimeletterViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var currentYear by remember { mutableIntStateOf(LocalDate.now().year) } + var currentMonth by remember { mutableIntStateOf(LocalDate.now().monthValue) } + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + + Scaffold( + modifier = modifier, + topBar = { HomeTopBar() }, + ) { innerPadding -> + when (val state = uiState) { + is RecipientTimeletterUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is RecipientTimeletterUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "타임레터를 불러올 수 없습니다.", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.load() }) { + Text("다시 시도") + } + } + } + } + + is RecipientTimeletterUiState.Success -> { + RecipientTimeletterContent( + letters = state.letters, + currentYear = currentYear, + currentMonth = currentMonth, + selectedDate = selectedDate, + onDateSelect = { day -> + selectedDate = LocalDate.of(currentYear, currentMonth, day) + }, + onLetterClick = onLetterClick, + modifier = Modifier.padding(innerPadding), + ) + } + } + } +} + +@Composable +private fun RecipientTimeletterContent( + letters: ReceivedTimeLetterList, + currentYear: Int, + currentMonth: Int, + selectedDate: LocalDate, + onDateSelect: (Int) -> Unit, + onLetterClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + item { + CalendarGridContent( + currentYear = currentYear, + currentMonth = currentMonth, + selectedDate = selectedDate, + onDateSelect = onDateSelect, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) + } + + item { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = AfternoteDesign.colors.gray2, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + items(letters.timeLetters) { letter -> + ReceivedTimeLetterItem( + letter = letter, + onClick = { onLetterClick(letter.timeLetterReceiverId) }, + ) + } + } +} + +@Composable +private fun ReceivedTimeLetterItem( + letter: ReceivedTimeLetter, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = letter.title ?: "제목 없음", + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "발신인 ${letter.senderName ?: ""}", + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } + Text( + text = letter.deliveredAt?.take(10)?.replace("-", ".") ?: "", + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray4, + ) + } +} + +private val previewLetters = + ReceivedTimeLetterList( + timeLetters = + listOf( + ReceivedTimeLetter( + id = 1L, + timeLetterReceiverId = 1L, + title = "채연아 20번째 생일을 축하해", + sendAt = "2027-11-24T00:00:00", + status = TimeLetterStatus.SENT, + senderName = "박경민", + deliveredAt = "2027-11-24T00:00:00", + createdAt = "2026-01-01T00:00:00", + isRead = true, + blocks = emptyList(), + ), + ReceivedTimeLetter( + id = 2L, + timeLetterReceiverId = 2L, + title = "10년 뒤의 너에게", + sendAt = "2036-05-30T00:00:00", + status = TimeLetterStatus.SENT, + senderName = "이하늘", + deliveredAt = "2036-05-30T00:00:00", + createdAt = "2026-01-15T00:00:00", + isRead = false, + blocks = emptyList(), + ), + ), + totalCount = 2, + ) + +@Preview(showBackground = true) +@Composable +private fun RecipientTimeletterContentPreview() { + RecipientTimeletterContent( + letters = previewLetters, + currentYear = 2027, + currentMonth = 11, + selectedDate = LocalDate.of(2027, 11, 24), + onDateSelect = {}, + onLetterClick = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun RecipientTimeletterContentEmptyPreview() { + RecipientTimeletterContent( + letters = ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 0), + currentYear = LocalDate.now().year, + currentMonth = LocalDate.now().monthValue, + selectedDate = LocalDate.now(), + onDateSelect = {}, + onLetterClick = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ReceivedTimeLetterItemPreview() { + ReceivedTimeLetterItem( + letter = previewLetters.timeLetters.first(), + onClick = {}, + ) +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeletterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeletterViewModel.kt new file mode 100644 index 000000000..00acf47f3 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientTimeletterViewModel.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface RecipientTimeletterUiState { + data object Loading : RecipientTimeletterUiState + + data class Success( + val letters: ReceivedTimeLetterList, + ) : RecipientTimeletterUiState + + data object Error : RecipientTimeletterUiState +} + +@HiltViewModel +class RecipientTimeletterViewModel + @Inject + constructor( + private val receiverTimeLetterRepository: ReceiverTimeLetterRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(RecipientTimeletterUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _uiState.value = RecipientTimeletterUiState.Loading + runCatching { receiverTimeLetterRepository.getReceivedTimeLetters() } + .onSuccess { letters -> + _uiState.value = RecipientTimeletterUiState.Success(letters = letters) + }.onFailure { + _uiState.value = RecipientTimeletterUiState.Error + } + } + } + } From a980520e9a5f727eaa8a44d3d51af93f68042623 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 30 May 2026 01:30:22 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=EB=8B=AC=20=ED=99=94=EC=82=B4=ED=91=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=97=AC=EB=B0=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../core/ui/calendar/BottomSheetCalendar.kt | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt index 8d002668d..2943c7836 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -31,9 +32,11 @@ 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.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.R import com.afternote.core.ui.theme.AfternoteDesign @@ -128,7 +131,7 @@ fun DatePickerContent( ) { Text( text = title, - style = AfternoteDesign.typography.h3, + style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, @@ -147,7 +150,7 @@ fun DatePickerContent( ) { Text( text = formattedDate, - style = AfternoteDesign.typography.h3, + style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) } @@ -169,29 +172,43 @@ fun DatePickerContent( ) { Text( text = "${currentYear}년 ${currentMonth}월", - style = AfternoteDesign.typography.h3, + style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray9, modifier = Modifier.weight(1f), ) - Icon( - painter = painterResource(R.drawable.core_ui_arrow_left), - contentDescription = "이전 달", + Box( modifier = Modifier - .size(24.dp) + .height(8.dp) + .width(4.dp) + .clipToBounds() .clickable { onPrevMonth() }, - tint = AfternoteDesign.colors.gray9, - ) + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.core_ui_arrow_left), + contentDescription = "이전 달", + modifier = Modifier.size(24.dp), + tint = AfternoteDesign.colors.gray9, + ) + } Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(R.drawable.core_ui_right), - contentDescription = "다음 달", + Box( modifier = Modifier - .size(24.dp) + .height(8.dp) + .width(4.dp) + .clipToBounds() .clickable { onNextMonth() }, - tint = AfternoteDesign.colors.gray9, - ) + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.core_ui_right_arrow), + contentDescription = "다음 달", + modifier = Modifier.size(24.dp), + tint = AfternoteDesign.colors.gray9, + ) + } } Spacer(modifier = Modifier.height(16.dp)) @@ -288,6 +305,32 @@ fun PickerDayCell( } } +@Preview(showBackground = true) +@Composable +private fun DatePickerContentPreview() { + DatePickerContent( + title = "발송 예정일", + currentYear = 2026, + currentMonth = 5, + selectedDate = LocalDate.of(2026, 5, 30), + onPrevMonth = {}, + onNextMonth = {}, + onDateSelect = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun CalendarGridContentPreview() { + CalendarGridContent( + currentYear = 2026, + currentMonth = 5, + selectedDate = LocalDate.of(2026, 5, 30), + onDateSelect = {}, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) +} + fun buildPickerDays( year: Int, month: Int, From 7db56aac8d43aaa04e8f100bcab8f8c8cc7f82ac Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 30 May 2026 01:48:29 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=EC=9D=84=20TimeLetterListItem/Block?= =?UTF-8?q?Item=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B7=B0=20=EB=AA=A8=EB=93=9C=20=ED=86=A0=EA=B8=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../recipient/RecipientTimeletterScreen.kt | 146 ++++++++++++------ 1 file changed, 99 insertions(+), 47 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt index 5d66897df..c539126ba 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -1,6 +1,7 @@ package com.afternote.feature.timeletter.presentation.screen.recipient import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -33,9 +34,14 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.HomeTopBar import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.component.TimeLetterBlockItem +import com.afternote.feature.timeletter.presentation.component.TimeLetterListItem +import com.afternote.feature.timeletter.presentation.component.ViewToggleButton import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeletterUiState import com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeletterViewModel +import com.afternote.feature.timeletter.presentation.viewmodel.ViewMode import java.time.LocalDate @Composable @@ -110,6 +116,8 @@ private fun RecipientTimeletterContent( onLetterClick: (Long) -> Unit, modifier: Modifier = Modifier, ) { + var viewMode by remember { mutableStateOf(ViewMode.List) } + LazyColumn(modifier = modifier) { item { CalendarGridContent( @@ -126,52 +134,46 @@ private fun RecipientTimeletterContent( modifier = Modifier.padding(horizontal = 20.dp), color = AfternoteDesign.colors.gray2, ) - Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + ViewToggleButton(viewMode = viewMode, onViewModeChange = { viewMode = it }) + } } items(letters.timeLetters) { letter -> - ReceivedTimeLetterItem( - letter = letter, - onClick = { onLetterClick(letter.timeLetterReceiverId) }, - ) + val timeLetter = letter.toTimeLetter() + val senderNameMap = mapOf(letter.id to (letter.senderName ?: "")) + when (viewMode) { + ViewMode.List -> TimeLetterListItem( + letter = timeLetter, + receiverNameMap = senderNameMap, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) }, + ) + ViewMode.Block -> TimeLetterBlockItem( + letter = timeLetter, + receiverNameMap = senderNameMap, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) }, + ) + } } } } -@Composable -private fun ReceivedTimeLetterItem( - letter: ReceivedTimeLetter, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = letter.title ?: "제목 없음", - style = AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.gray9, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "발신인 ${letter.senderName ?: ""}", - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - ) - } - Text( - text = letter.deliveredAt?.take(10)?.replace("-", ".") ?: "", - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray4, - ) - } -} +private fun ReceivedTimeLetter.toTimeLetter() = TimeLetter( + id = id, + title = title, + sendAt = deliveredAt, + deliveredAt = deliveredAt, + status = status, + blocks = blocks, + receiverIds = listOf(id), +) private val previewLetters = ReceivedTimeLetterList( @@ -205,6 +207,64 @@ private val previewLetters = totalCount = 2, ) +@Preview(showBackground = true) +@Composable +private fun RecipientTimeletterScreenPreview() { + Scaffold( + topBar = { HomeTopBar() }, + ) { innerPadding -> + RecipientTimeletterContent( + letters = previewLetters, + currentYear = 2027, + currentMonth = 11, + selectedDate = LocalDate.of(2027, 11, 24), + onDateSelect = {}, + onLetterClick = {}, + modifier = Modifier.padding(innerPadding), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecipientTimeletterScreenLoadingPreview() { + Scaffold( + topBar = { HomeTopBar() }, + ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RecipientTimeletterScreenErrorPreview() { + Scaffold( + topBar = { HomeTopBar() }, + ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "타임레터를 불러올 수 없습니다.", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = {}) { + Text("다시 시도") + } + } + } + } +} + @Preview(showBackground = true) @Composable private fun RecipientTimeletterContentPreview() { @@ -231,11 +291,3 @@ private fun RecipientTimeletterContentEmptyPreview() { ) } -@Preview(showBackground = true) -@Composable -private fun ReceivedTimeLetterItemPreview() { - ReceivedTimeLetterItem( - letter = previewLetters.timeLetters.first(), - onClick = {}, - ) -} From 67ed4da477181c1817ae5a0b4e67b4c89c1ebe94 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 30 May 2026 02:30:39 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A0=ED=83=9D=20=EB=82=A0=EC=A7=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../component/TimeLetterBlockItem.kt | 55 ++++++++------- .../recipient/RecipientTimeletterScreen.kt | 67 +++++++++++++------ 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt index c4b6e8a12..162b7bd16 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt @@ -31,6 +31,7 @@ fun TimeLetterBlockItem( letter: TimeLetter, modifier: Modifier = Modifier, receiverNameMap: Map = emptyMap(), + showMetaInfo: Boolean = true, ) { val thumbUrl = letter.blocks.firstOrNull { it.blockType == TimeLetterBlockType.IMAGE }?.url @@ -60,33 +61,35 @@ fun TimeLetterBlockItem( Column( modifier = Modifier.padding(vertical = 16.dp, horizontal = 15.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val receiverText = - letter.receiverIds - .mapNotNull { receiverNameMap[it] } - .joinToString(", ") - .ifEmpty { "${letter.receiverIds.size}명" } - Text( - text = "수신인 $receiverText", - style = AfternoteDesign.typography.bodySmallR, - color = AfternoteDesign.colors.gray6, - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "발송 예정일 ${letter.sendAt?.replace("-", ".") ?: ""}", - style = AfternoteDesign.typography.bodySmallR, - color = AfternoteDesign.colors.gray6, - ) - Spacer(modifier = Modifier.width(43.dp)) - Image( - painterResource(com.afternote.feature.timeletter.presentation.R.drawable.setting), - contentDescription = "더보기 설정", - ) + if (showMetaInfo) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val receiverText = + letter.receiverIds + .mapNotNull { receiverNameMap[it] } + .joinToString(", ") + .ifEmpty { "${letter.receiverIds.size}명" } + Text( + text = "수신인 $receiverText", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "발송 예정일 ${letter.sendAt?.replace("-", ".") ?: ""}", + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.width(43.dp)) + Image( + painterResource(com.afternote.feature.timeletter.presentation.R.drawable.setting), + contentDescription = "더보기 설정", + ) + } + Spacer(modifier = Modifier.padding(top = 7.dp)) } - Spacer(modifier = Modifier.padding(top = 7.dp)) Text( text = letter.title ?: "제목 없음", diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt index c539126ba..672e37d7f 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -117,8 +117,36 @@ private fun RecipientTimeletterContent( modifier: Modifier = Modifier, ) { var viewMode by remember { mutableStateOf(ViewMode.List) } + val selectedDateLetters = letters.timeLetters.filter { + it.deliveredAt?.take(10) == selectedDate.toString() + } LazyColumn(modifier = modifier) { + item { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text("타임레터", style = AfternoteDesign.typography.h1) + Spacer(modifier = Modifier.padding(8.dp)) + Text( + "서연님이 남긴 마음을 확인해보세요", + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } + } + + if (selectedDateLetters.isNotEmpty()) { + items(selectedDateLetters) { letter -> + TimeLetterBlockItem( + letter = letter.toTimeLetter(), + receiverNameMap = mapOf(letter.id to (letter.senderName ?: "")), + showMetaInfo = false, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) }, + ) + } + } + item { CalendarGridContent( currentYear = currentYear, @@ -145,35 +173,37 @@ private fun RecipientTimeletterContent( items(letters.timeLetters) { letter -> val timeLetter = letter.toTimeLetter() val senderNameMap = mapOf(letter.id to (letter.senderName ?: "")) - when (viewMode) { - ViewMode.List -> TimeLetterListItem( + val itemModifier = Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) } + if (viewMode == ViewMode.Block) { + TimeLetterBlockItem( letter = timeLetter, receiverNameMap = senderNameMap, - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 6.dp) - .clickable { onLetterClick(letter.timeLetterReceiverId) }, + showMetaInfo = false, + modifier = itemModifier, ) - ViewMode.Block -> TimeLetterBlockItem( + } else { + TimeLetterListItem( letter = timeLetter, receiverNameMap = senderNameMap, - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 6.dp) - .clickable { onLetterClick(letter.timeLetterReceiverId) }, + modifier = itemModifier, ) } } } } -private fun ReceivedTimeLetter.toTimeLetter() = TimeLetter( - id = id, - title = title, - sendAt = deliveredAt, - deliveredAt = deliveredAt, - status = status, - blocks = blocks, - receiverIds = listOf(id), -) +private fun ReceivedTimeLetter.toTimeLetter() = + TimeLetter( + id = id, + title = title, + sendAt = deliveredAt, + deliveredAt = deliveredAt, + status = status, + blocks = blocks, + receiverIds = listOf(id), + ) private val previewLetters = ReceivedTimeLetterList( @@ -290,4 +320,3 @@ private fun RecipientTimeletterContentEmptyPreview() { onLetterClick = {}, ) } - From b30d8adcfc5e2348e5cbba95bdde084c66549aa5 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 30 May 2026 02:52:58 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EB=A0=88?= =?UTF-8?q?=ED=84=B0=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=98=88=EC=A0=95=EC=9D=BC=20=EC=8B=9C=EA=B0=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/component/TimeLetterBlockItem.kt | 9 ++++----- .../presentation/component/TimeletterListItem.kt | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt index 162b7bd16..c7b5eb40b 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt @@ -73,13 +73,13 @@ fun TimeLetterBlockItem( .ifEmpty { "${letter.receiverIds.size}명" } Text( text = "수신인 $receiverText", - style = AfternoteDesign.typography.bodySmallR, + style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.gray6, ) Spacer(modifier = Modifier.weight(1f)) Text( - text = "발송 예정일 ${letter.sendAt?.replace("-", ".") ?: ""}", - style = AfternoteDesign.typography.bodySmallR, + text = "발송 예정일 ${letter.sendAt?.take(10)?.replace("-", ".") ?: ""}", + style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.gray6, ) Spacer(modifier = Modifier.width(43.dp)) @@ -93,8 +93,7 @@ fun TimeLetterBlockItem( Text( text = letter.title ?: "제목 없음", - style = AfternoteDesign.typography.bodyLargeB, - fontWeight = FontWeight.W600, + style = AfternoteDesign.typography.bodySmallR, ) Spacer(modifier = Modifier.padding(top = 5.dp)) Text( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt index 825e05e13..78f87506f 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt @@ -57,7 +57,7 @@ fun TimeLetterListItem( ) Spacer(modifier = Modifier.weight(1f)) Text( - text = "발송 예정일 ${letter.sendAt?.replace("-", ".") ?: ""}", + text = "발송 예정일 ${letter.sendAt?.take(10)?.replace("-", ".") ?: ""}", style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.gray6, ) From 51973b746da2f2e6ea904338b94cd6b07c0a9d42 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sun, 31 May 2026 00:16:30 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20timeletter=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recipient/RecipientTimeletterScreen.kt | 108 ++++++------------ 1 file changed, 36 insertions(+), 72 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt index 672e37d7f..4c78cacc5 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -10,6 +10,7 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button @@ -27,6 +28,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.calendar.CalendarGridContent @@ -117,9 +120,10 @@ private fun RecipientTimeletterContent( modifier: Modifier = Modifier, ) { var viewMode by remember { mutableStateOf(ViewMode.List) } - val selectedDateLetters = letters.timeLetters.filter { - it.deliveredAt?.take(10) == selectedDate.toString() - } + val selectedDateLetters = + letters.timeLetters.filter { + it.deliveredAt?.take(10) == selectedDate.toString() + } LazyColumn(modifier = modifier) { item { @@ -133,6 +137,27 @@ private fun RecipientTimeletterContent( ) } } + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Today’s letter", + style = AfternoteDesign.typography.mono, + ) + Spacer(modifier = Modifier.width(16.dp)) + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = 1.dp, + color = AfternoteDesign.colors.gray3, + ) + } + } if (selectedDateLetters.isNotEmpty()) { items(selectedDateLetters) { letter -> @@ -140,9 +165,10 @@ private fun RecipientTimeletterContent( letter = letter.toTimeLetter(), receiverNameMap = mapOf(letter.id to (letter.senderName ?: "")), showMetaInfo = false, - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 6.dp) - .clickable { onLetterClick(letter.timeLetterReceiverId) }, + modifier = + Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) }, ) } } @@ -173,9 +199,10 @@ private fun RecipientTimeletterContent( items(letters.timeLetters) { letter -> val timeLetter = letter.toTimeLetter() val senderNameMap = mapOf(letter.id to (letter.senderName ?: "")) - val itemModifier = Modifier - .padding(horizontal = 20.dp, vertical = 6.dp) - .clickable { onLetterClick(letter.timeLetterReceiverId) } + val itemModifier = + Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .clickable { onLetterClick(letter.timeLetterReceiverId) } if (viewMode == ViewMode.Block) { TimeLetterBlockItem( letter = timeLetter, @@ -255,68 +282,5 @@ private fun RecipientTimeletterScreenPreview() { } } -@Preview(showBackground = true) -@Composable -private fun RecipientTimeletterScreenLoadingPreview() { - Scaffold( - topBar = { HomeTopBar() }, - ) { innerPadding -> - Box( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } -} - -@Preview(showBackground = true) -@Composable -private fun RecipientTimeletterScreenErrorPreview() { - Scaffold( - topBar = { HomeTopBar() }, - ) { innerPadding -> - Box( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "타임레터를 불러올 수 없습니다.", - style = AfternoteDesign.typography.bodySmallR, - color = AfternoteDesign.colors.gray6, - ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = {}) { - Text("다시 시도") - } - } - } - } -} -@Preview(showBackground = true) -@Composable -private fun RecipientTimeletterContentPreview() { - RecipientTimeletterContent( - letters = previewLetters, - currentYear = 2027, - currentMonth = 11, - selectedDate = LocalDate.of(2027, 11, 24), - onDateSelect = {}, - onLetterClick = {}, - ) -} -@Preview(showBackground = true) -@Composable -private fun RecipientTimeletterContentEmptyPreview() { - RecipientTimeletterContent( - letters = ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 0), - currentYear = LocalDate.now().year, - currentMonth = LocalDate.now().monthValue, - selectedDate = LocalDate.now(), - onDateSelect = {}, - onLetterClick = {}, - ) -} From 1090eae1692165ce1d90d82fe0a4f484f77af48a Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sun, 31 May 2026 02:17:11 +0900 Subject: [PATCH 09/10] ktlint 1 --- .../afternote/feature/timeletter/data/di/TimeLetterModule.kt | 5 ++--- .../screen/recipient/RecipientTimeletterScreen.kt | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt index cc7e86907..fcd382180 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt @@ -42,7 +42,6 @@ object TimeLetterModule { @Provides @Singleton - fun provideReceiverTimeLetterRepository( - receiverTimeLetterApiService: ReceiverTimeLetterApiService, - ): ReceiverTimeLetterRepository = ReceiverTimeLetterRepositoryImpl(receiverTimeLetterApiService) + fun provideReceiverTimeLetterRepository(receiverTimeLetterApiService: ReceiverTimeLetterApiService): ReceiverTimeLetterRepository = + ReceiverTimeLetterRepositoryImpl(receiverTimeLetterApiService) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt index 4c78cacc5..e35ff506f 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -281,6 +281,3 @@ private fun RecipientTimeletterScreenPreview() { ) } } - - - From 3e4f063ece9b8d2fa3112ad5a6747e89936ed8c9 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sun, 31 May 2026 02:55:52 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EC=8B=A4=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B4=20=EA=B1=B0=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/MemorialReceivedDetailScreen.kt | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt new file mode 100644 index 000000000..50affdec9 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt @@ -0,0 +1,313 @@ +package com.afternote.feature.afternote.presentation.receiver.detail + +import android.content.Intent +import android.content.pm.PackageManager +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 +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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.core.net.toUri +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import com.afternote.core.model.AlbumCover +import com.afternote.core.ui.ProfileImage +import com.afternote.core.ui.bottombar.BottomBar +import com.afternote.core.ui.bottombar.BottomNavTab +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.shared.LastWishesRadioGroup +import com.afternote.feature.afternote.presentation.shared.MemorialGuidelineContent +import com.afternote.feature.afternote.presentation.shared.detail.InfoCard +import com.afternote.feature.afternote.presentation.shared.detail.song.MemorialPlaylist + +/** + * MEMORIAL 카테고리의 수신자 측 detail prototype. + * + * 현재 [ReceivedAfternoteDetailSuccessMapper] 의 MEMORIAL 분기는 디자이너 보류라 + * [com.afternote.feature.afternote.presentation.author.navigation.DesignPendingDetailContent] 로 폴백 + * 한다. 디자인 확정 시 mapper 에 MEMORIAL UI 모델을 추가하고 [ReceivedAfternoteDetailRoute] 의 + * when 분기에서 본 화면을 호출하도록 wire-up 한다. + * + * 페어 sub-screen: [com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen] + * (추모 플레이리스트 진입). + */ +@Composable +fun MemorialReceivedDetailScreen( + senderName: String, + onNavigateToFullList: () -> Unit = {}, + onNavigateToPlaylist: () -> Unit = {}, + onBackClick: () -> Unit = {}, + profileImageResId: Int? = null, + albumCovers: List, + songCount: Int = 16, + memorialVideoUrl: String? = null, + memorialThumbnailUrl: String? = null, + showBottomBar: Boolean = true, +) { + var selectedBottomNavItem by remember { mutableStateOf(BottomNavTab.TIMELETTER) } + profileImageResId ?: R.drawable.feature_afternote_img_default_profile_deceased + + Scaffold( + containerColor = Color.Transparent, + topBar = { + Column(modifier = Modifier.statusBarsPadding()) { + DetailTopBar( + title = "故${senderName}님의 애프터노트", + onBackClick = { onBackClick() }, + ) + } + }, + bottomBar = { + if (showBottomBar) { + BottomBar( + selectedNavTab = selectedBottomNavItem, + onTabClick = { selectedBottomNavItem = it }, + ) + } + }, + ) { innerPadding -> + LazyColumn( + modifier = + Modifier + .padding(innerPadding) + .padding(20.dp) + .fillMaxSize(), + contentPadding = PaddingValues(vertical = 20.dp), + ) { + item { + MemorialGuidelineContent( + introContent = { + Text( + text = "故 ${senderName}님의 애프터노트입니다.", + style = + AfternoteDesign.typography.textField.copy( + fontWeight = FontWeight.Medium, + color = AfternoteDesign.colors.gray9, + ), + modifier = Modifier.fillMaxWidth(), + ) + }, + photoContent = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileImage() + } + }, + playlistContent = { + MemorialPlaylist( + label = "추모 플레이리스트", + songCount = songCount, + albumCovers = albumCovers, + onAddSongClick = null, + onPlaylistClick = onNavigateToPlaylist, + ) + }, + lastWishContent = { + LastWishesRadioGroup( + displayTextOnly = "끼니 거르지 말고 건강 챙기고 지내.", + ) + }, + sectionSpacing = 32.dp, + videoContent = { + ReceiverVideoSection( + memorialVideoUrl = memorialVideoUrl, + memorialThumbnailUrl = memorialThumbnailUrl, + ) + }, + ) + } + item { + Spacer(modifier = Modifier.height(70.dp)) + + AfternoteButton( + text = "애프터노트 확인하기", + onClick = onNavigateToFullList, + type = AfternoteButtonType.Default, + ) + Spacer(modifier = Modifier.height(20.dp)) + } + } + } +} + +private const val LABEL_VIDEO_SECTION = "장례식에 남길 영상" + +@Composable +private fun ReceiverVideoSection( + memorialVideoUrl: String? = null, + memorialThumbnailUrl: String? = null, +) { + val context = LocalContext.current + Column(modifier = Modifier.fillMaxWidth()) { + ReceiverSectionHeader() + Spacer(modifier = Modifier.height(12.dp)) + if (!memorialVideoUrl.isNullOrBlank()) { + InfoCard( + modifier = + Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, memorialVideoUrl.toUri()) + if (context.packageManager.resolveActivity( + intent, + PackageManager.MATCH_DEFAULT_ONLY, + ) != null + ) { + context.startActivity(intent) + } else { + Toast + .makeText( + context, + "영상을 재생할 수 있는 앱이 없습니다.", + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + ReceiverMemorialVideoThumbnail(thumbnailUrl = memorialThumbnailUrl) + } + } else { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AfternoteDesign.colors.gray3), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Play", + tint = AfternoteDesign.colors.white, + modifier = + Modifier + .size(48.dp) + .background( + AfternoteDesign.colors.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()) { + val ctx = LocalContext.current + val imageRequest = + remember(thumbnailUrl) { + ImageRequest + .Builder(ctx) + .data(thumbnailUrl) + .httpHeaders( + NetworkHeaders + .Builder() + .set("User-Agent", "Afternote Android App") + .build(), + ).build() + } + AsyncImage( + model = imageRequest, + contentDescription = "장례식에 남길 영상 썸네일", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + Box( + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + AfternoteDesign.colors.gray6.copy(alpha = 153f / 255f), + AfternoteDesign.colors.gray9.copy(alpha = 153f / 255f), + ), + ), + ), + ) + Image( + painter = painterResource(R.drawable.feature_afternote_ic_playback), + contentDescription = "영상 재생", + modifier = + Modifier + .align(Alignment.Center) + .size(32.dp), + ) + } +} + +@Composable +private fun ReceiverSectionHeader(title: String = LABEL_VIDEO_SECTION) { + Text( + text = title, + style = + AfternoteDesign.typography.textField.copy( + fontWeight = FontWeight.Medium, + color = AfternoteDesign.colors.gray9, + ), + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewMemorialReceivedDetail() { + AfternoteTheme { + MemorialReceivedDetailScreen(senderName = "박서연", albumCovers = emptyList()) + } +}