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..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,71 +172,103 @@ 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)) - 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)) } } } @@ -270,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, 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..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 @@ -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,14 @@ 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 +} 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..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 @@ -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,38 +61,39 @@ 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.footnoteCaption, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "발송 예정일 ${letter.sendAt?.take(10)?.replace("-", ".") ?: ""}", + style = AfternoteDesign.typography.footnoteCaption, + 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 ?: "제목 없음", - 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, ) 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..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 @@ -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(showBackground = true) +@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/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..e35ff506f --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -0,0 +1,283 @@ +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 +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.width +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.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 +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 +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, +) { + 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, + ) + } + } + 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 -> + 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, + 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, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + ViewToggleButton(viewMode = viewMode, onViewModeChange = { viewMode = it }) + } + } + + 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) } + if (viewMode == ViewMode.Block) { + TimeLetterBlockItem( + letter = timeLetter, + receiverNameMap = senderNameMap, + showMetaInfo = false, + modifier = itemModifier, + ) + } else { + TimeLetterListItem( + letter = timeLetter, + receiverNameMap = senderNameMap, + modifier = itemModifier, + ) + } + } + } +} + +private fun ReceivedTimeLetter.toTimeLetter() = + TimeLetter( + id = id, + title = title, + sendAt = deliveredAt, + deliveredAt = deliveredAt, + status = status, + blocks = blocks, + receiverIds = listOf(id), + ) + +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 RecipientTimeletterScreenPreview() { + Scaffold( + topBar = { HomeTopBar() }, + ) { innerPadding -> + RecipientTimeletterContent( + letters = previewLetters, + currentYear = 2027, + currentMonth = 11, + selectedDate = LocalDate.of(2027, 11, 24), + onDateSelect = {}, + onLetterClick = {}, + modifier = Modifier.padding(innerPadding), + ) + } +} 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 + } + } + } + } 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 + } + } + } + }