diff --git a/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt b/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt new file mode 100644 index 00000000..a2e0ad7d --- /dev/null +++ b/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt @@ -0,0 +1,16 @@ +package com.example.common.event + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HomeRefreshTrigger + @Inject + constructor() { + private val _refreshEvent = MutableSharedFlow() + val refreshEvent = _refreshEvent.asSharedFlow() + + suspend fun refresh() = _refreshEvent.emit(Unit) + } diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt index 318d896d..8291ed3e 100644 --- a/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt +++ b/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt @@ -9,6 +9,7 @@ fun PostDetailResponse.toDomain(): PostDetail = isHost = this.isHost, isScrapped = this.isScrapped, content = this.content, + date = this.displayDate, track = this.track.toDomain(), writer = this.user.toDomain(), like = this.like.toDomain(), diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt index 771289e4..19759aa9 100644 --- a/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt +++ b/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt @@ -1,10 +1,9 @@ package com.example.data.mapper.todomain -import com.example.data.model.response.BadgesResponse import com.example.data.model.response.TodayPostItemResponse import com.example.data.model.response.TodayPostTrackResponse import com.example.data.model.response.TodayPostsResponse -import com.example.domain.model.Badges +import com.example.domain.model.BADGE import com.example.domain.model.DailyQuestion import com.example.domain.model.FeedItem import com.example.domain.model.HomeScreenData @@ -29,19 +28,12 @@ fun TodayPostItemResponse.toDomain(): FeedItem = postId = postId, isScrapped = isScrapped, content = content, - badges = badges.toDomain(), + badge = badge?.let { BADGE.valueOf(it) }, track = track.toDomain(), writer = user.toDomain(), like = like.toDomain(), ) -private fun BadgesResponse.toDomain(): Badges = - Badges( - isEditorPick = isEditorPick, - isPopular = isPopular, - isNew = isNew, - ) - private fun TodayPostTrackResponse.toDomain(): Track = Track( trackId = trackId, diff --git a/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt b/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt index c1eb8c9a..24a0caed 100644 --- a/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt +++ b/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt @@ -13,6 +13,8 @@ data class PostDetailResponse( val isScrapped: Boolean, @SerialName("content") val content: String, + @SerialName("displayDate") + val displayDate: String, @SerialName("track") val track: TrackResponse, @SerialName("user") diff --git a/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt b/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt index 413fa4db..22cfbeaa 100644 --- a/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt +++ b/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt @@ -1,6 +1,6 @@ package com.example.data.model.response -import com.example.domain.model.Badges +import com.example.domain.model.BADGE import com.example.domain.model.FeedItem import com.example.domain.model.Like import com.example.domain.model.Track @@ -52,12 +52,7 @@ data class QuestionPostItemResponse( postId = postId, isScrapped = isScrapped, content = content, - badges = - Badges( - isEditorPick = isEditorPick, - isPopular = false, - isNew = false, - ), + badge = if (isEditorPick) BADGE.EDITOR else null, track = Track( trackId = track.trackId, diff --git a/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt b/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt index e38a3109..f64812a9 100644 --- a/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt +++ b/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt @@ -19,19 +19,12 @@ data class TodayPostItemResponse( @SerialName("postId") val postId: Long, @SerialName("isScrapped") val isScrapped: Boolean, @SerialName("content") val content: String, - @SerialName("badges") val badges: BadgesResponse, + @SerialName("badge") val badge: String?, @SerialName("track") val track: TodayPostTrackResponse, @SerialName("user") val user: UserResponse, @SerialName("like") val like: LikeResponse, ) -@Serializable -data class BadgesResponse( - @SerialName("isEditorPick") val isEditorPick: Boolean, - @SerialName("isPopular") val isPopular: Boolean, - @SerialName("isNew") val isNew: Boolean, -) - @Serializable data class TodayPostTrackResponse( @SerialName("trackId") val trackId: String, diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt index d1660793..d9eaa74f 100644 --- a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt @@ -108,6 +108,7 @@ fun DPlayTitleButtonBottomSheet( onButtonClick: () -> Unit, onCloseClick: () -> Unit, modifier: Modifier = Modifier, + isButtonEnabled: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { val bottomSheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) @@ -164,39 +165,36 @@ fun DPlayTitleButtonBottomSheet( .padding(horizontal = 8.5.dp), onClick = onButtonClick, label = buttonText, + enabled = isButtonEnabled, ) } } @Composable fun DPlayReportBottomSheet( - onButtonClick: (selectedReasons: List) -> Unit, + onButtonClick: (selectedReason: DPlayReportReason) -> Unit, onCloseClick: () -> Unit, modifier: Modifier = Modifier, onCheckClick: ((DPlayReportReason) -> Unit)? = null, reasons: List = DPlayReportReason.entries, ) { - var selectedReasons by remember { mutableStateOf(setOf()) } + var selectedReason by remember { mutableStateOf(null) } DPlayTitleButtonBottomSheet( titleText = stringResource(R.string.report_bottom_sheet_title), buttonText = "신고하기", - onButtonClick = { onButtonClick(selectedReasons.toList()) }, + onButtonClick = { selectedReason?.let { onButtonClick(it) } }, onCloseClick = onCloseClick, modifier = modifier, + isButtonEnabled = selectedReason != null, ) { reasons.forEach { reason -> - val isChecked = reason in selectedReasons + val isChecked = reason == selectedReason DPlayCheck( text = stringResource(reason.stringResId), isChecked = isChecked, onClick = { - selectedReasons = - if (isChecked) { - selectedReasons - reason - } else { - selectedReasons + reason - } + selectedReason = if (isChecked) null else reason onCheckClick?.invoke(reason) }, modifier = Modifier.fillMaxWidth(), diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt index 61d13938..7f5f3404 100644 --- a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border 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.fillMaxWidth @@ -33,11 +32,9 @@ import coil3.compose.AsyncImage import com.dplay.designsystem.R import com.example.designsystem.theme.DPlayTheme import com.example.designsystem.util.noRippleClickable -import com.example.designsystem.util.roundedBackgroundWithPadding @Composable fun DPlayLargeCover( - isBookmarkChecked: Boolean, isLikeChecked: Boolean, likeCount: Int, writerProfileImageUrl: String?, @@ -48,10 +45,8 @@ fun DPlayLargeCover( onWriterProfileClick: () -> Unit, onStreamClick: () -> Unit, onLikeClick: () -> Unit, - onBookmarkClick: () -> Unit, modifier: Modifier = Modifier, isLocked: Boolean = true, - bookmarkIconVisible: Boolean = true, isStreaming: Boolean = false, ) { val color = DPlayTheme.colors @@ -91,25 +86,6 @@ fun DPlayLargeCover( .align(Alignment.TopCenter), ) - if (bookmarkIconVisible && !isLocked) { - DplayClickableIcon( - iconRes = - if (isBookmarkChecked) { - R.drawable.ic_bookmark_filled_24 - } else { - R.drawable.ic_bookmark_unfilled_24 - }, - modifier = - Modifier - .roundedBackgroundWithPadding( - backgroundColor = color.gray600, - padding = PaddingValues(10.dp), - cornerRadius = 12.dp, - ).align(Alignment.TopEnd), - onClick = onBookmarkClick, - ) - } - Column( modifier = Modifier @@ -229,7 +205,6 @@ fun DPlayLargeCover( private fun DPlayLargeCoverPreview() { DPlayTheme { DPlayLargeCover( - isBookmarkChecked = true, isLikeChecked = false, likeCount = 24, writerProfileImageUrl = "", @@ -238,7 +213,6 @@ private fun DPlayLargeCoverPreview() { musicImageUrl = "", onStreamClick = {}, onLikeClick = {}, - onBookmarkClick = {}, onCoverClick = {}, onWriterProfileClick = {}, ) diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt index e932010a..1252d8da 100644 --- a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.designsystem.theme.DPlayTheme @@ -30,9 +31,21 @@ fun DPlayMusicGridItem( .fillMaxWidth(), ) Spacer(modifier = Modifier.height(5.dp)) - Text(text = musicName, style = DPlayTheme.typography.bodySemi14, color = DPlayTheme.colors.dplayBlack) + Text( + text = musicName, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayBlack, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Spacer(modifier = Modifier.height(1.dp)) - Text(text = musicArtistName, style = DPlayTheme.typography.capMed12, color = DPlayTheme.colors.gray400) + Text( + text = musicArtistName, + style = DPlayTheme.typography.capMed12, + color = DPlayTheme.colors.gray400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt index 3358020a..28fc158e 100644 --- a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt @@ -36,8 +36,8 @@ fun DPlayMusicListItem( musicName: String, musicArtistName: String, musicContent: String, - onMoreClick: () -> Unit, modifier: Modifier = Modifier, + onMoreClick: (() -> Unit)? = null, isEditorPick: Boolean = false, onClick: () -> Unit = {}, ) { @@ -69,11 +69,13 @@ fun DPlayMusicListItem( ) Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { - DplayClickableIcon( - iconRes = R.drawable.ic_more_gray_20, - onClick = onMoreClick, - modifier = Modifier.align(Alignment.End), - ) + onMoreClick?.let { + DplayClickableIcon( + iconRes = R.drawable.ic_more_gray_20, + onClick = it, + modifier = Modifier.align(Alignment.End), + ) + } BoxWithConstraints { val maxTitleWidth = maxWidth * 0.5f diff --git a/core/domain/src/main/java/com/example/domain/model/FeedItem.kt b/core/domain/src/main/java/com/example/domain/model/FeedItem.kt index 374b888c..b01a2d59 100644 --- a/core/domain/src/main/java/com/example/domain/model/FeedItem.kt +++ b/core/domain/src/main/java/com/example/domain/model/FeedItem.kt @@ -1,17 +1,18 @@ package com.example.domain.model + data class FeedItem( val postId: Long, val isScrapped: Boolean, val content: String, - val badges: Badges, + val badge: BADGE?, val track: Track, val writer: Writer, val like: Like, ) -data class Badges( - val isEditorPick: Boolean, - val isPopular: Boolean, - val isNew: Boolean, -) \ No newline at end of file +enum class BADGE { + EDITOR, + BEST, + NEW, +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/PostDetail.kt b/core/domain/src/main/java/com/example/domain/model/PostDetail.kt index 28665ce3..dba320be 100644 --- a/core/domain/src/main/java/com/example/domain/model/PostDetail.kt +++ b/core/domain/src/main/java/com/example/domain/model/PostDetail.kt @@ -1,11 +1,21 @@ package com.example.domain.model +import java.time.LocalDate +import java.time.format.DateTimeFormatter + data class PostDetail( val postId: Long, val isHost: Boolean, val isScrapped: Boolean, val content: String, + private val date: String, val track: Track, val writer: Writer, val like: Like, -) \ No newline at end of file +) { + val displayDate: String + get() = runCatching { + val parsedDate = LocalDate.parse(date, DateTimeFormatter.ISO_DATE) + "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" + }.getOrElse { "알 수 없는 날짜" } +} \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 462d024b..4af6de7a 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(projects.core.designsystem) implementation(projects.core.common) + implementation(projects.core.domain) implementation(projects.core.ui) } diff --git a/core/navigation/src/main/java/com/example/navigation/Navigator.kt b/core/navigation/src/main/java/com/example/navigation/Navigator.kt index 4e7ad552..7ba535b9 100644 --- a/core/navigation/src/main/java/com/example/navigation/Navigator.kt +++ b/core/navigation/src/main/java/com/example/navigation/Navigator.kt @@ -20,7 +20,7 @@ class Navigator( val shouldShowBottomSheet: Boolean get() = backStack.lastOrNull() is TopLevelRoute - val topLevelRoutes: ImmutableList = persistentListOf(Home, MyPage) + val topLevelRoutes: ImmutableList = persistentListOf(Home, MyPage()) fun navigateToTopLevelRoute(destination: TopLevelRoute) { clearAndNavigateTo(destination as NavKey) diff --git a/core/navigation/src/main/java/com/example/navigation/Route.kt b/core/navigation/src/main/java/com/example/navigation/Route.kt index 6a7fbc4c..2bf3f910 100644 --- a/core/navigation/src/main/java/com/example/navigation/Route.kt +++ b/core/navigation/src/main/java/com/example/navigation/Route.kt @@ -3,6 +3,7 @@ package com.example.navigation import androidx.annotation.DrawableRes import androidx.navigation3.runtime.NavKey import com.dplay.designsystem.R +import com.example.domain.model.BADGE import com.example.ui.model.TrackState import kotlinx.serialization.Serializable @@ -21,7 +22,15 @@ data object Home : TopLevelRoute, NavKey { get() = R.drawable.ic_home_disabled_32 } -data object MyPage : TopLevelRoute, NavKey { +enum class MyPageTab { + REGISTERED, + BOOKMARKED, +} + +data class MyPage( + val initialTab: MyPageTab = MyPageTab.REGISTERED, +) : TopLevelRoute, + NavKey { override val selectedIconRes: Int get() = R.drawable.ic_bookmark_active_32 override val unselectedIconRes: Int @@ -62,5 +71,5 @@ data object Record : NavKey @Serializable data class Detail( val postId: Long, - val date: String = "", + val badge: BADGE? = null, ) : NavKey diff --git a/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt b/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt index 328412fc..5aa68980 100644 --- a/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt +++ b/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt @@ -2,6 +2,7 @@ package com.example.comment import androidx.lifecycle.viewModelScope import com.example.common.constant.Url +import com.example.common.event.HomeRefreshTrigger import com.example.domain.repository.PostRepository import com.example.ui.base.BaseViewModel import com.example.ui.model.TrackState @@ -15,6 +16,7 @@ class CommentViewModel @Inject constructor( private val postRepository: PostRepository, + private val homeRefreshTrigger: HomeRefreshTrigger, ) : BaseViewModel( CommentContract.CommentState(), ) { @@ -60,6 +62,7 @@ class CommentViewModel track = track, comment = currentState.commentInput, ).onSuccess { + homeRefreshTrigger.refresh() setSideEffect(CommentContract.CommentSideEffect.NavigateToHome) }.onFailure { } diff --git a/feature/detail/src/main/java/com/example/detail/DetailContract.kt b/feature/detail/src/main/java/com/example/detail/DetailContract.kt index ffaecf3e..1d8a6b65 100644 --- a/feature/detail/src/main/java/com/example/detail/DetailContract.kt +++ b/feature/detail/src/main/java/com/example/detail/DetailContract.kt @@ -1,6 +1,7 @@ package com.example.detail import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.domain.model.BADGE import com.example.domain.model.Like import com.example.domain.model.Track import com.example.domain.model.Writer @@ -12,6 +13,7 @@ class DetailContract { val isScrapped: Boolean = false, val content: String = "", val isHost: Boolean = false, + val date: String = "", val track: Track = Track( trackId = "", @@ -31,18 +33,15 @@ class DetailContract { isLiked = false, count = 0, ), - val date: String = "2025-10-19", + val badge: BADGE? = null, val bottomSheetVisible: Boolean = false, val streamingTrackId: String? = null, - val currentUserId: Long = 0L, - ) : BaseContract.State { - val isMyPost: Boolean get() = currentUserId != 0L && currentUserId == writer.userId - } + ) : BaseContract.State sealed interface DetailIntent : BaseContract.Intent { data class LoadData( val postId: Long, - val date: String = "", + val badge: BADGE? = null, ) : DetailIntent data object OnBookmarkClick : DetailIntent @@ -63,12 +62,16 @@ class DetailContract { data object OnDeleteClick : DetailIntent + data object OnDeleteConfirmClick : DetailIntent + data class ChangeBottomSheetVisible( val visible: Boolean, ) : DetailIntent } sealed interface DetailSideEffect : BaseContract.SideEffect { + data object ShowDeleteConfirmModal : DetailSideEffect + data object NavigateBackStack : DetailSideEffect data object NavigateToWriterProfile : DetailSideEffect diff --git a/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt b/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt index 452046c9..f2f3d886 100644 --- a/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt +++ b/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt @@ -20,7 +20,7 @@ object DetailNavigationModule { ): EntryProviderScope.() -> Unit = { entry { args -> - DetailRoute(postId = args.postId, navigator = navigator, date = args.date) + DetailRoute(postId = args.postId, navigator = navigator, badge = args.badge) } } } diff --git a/feature/detail/src/main/java/com/example/detail/DetailScreen.kt b/feature/detail/src/main/java/com/example/detail/DetailScreen.kt index 6a4b6ccb..fa12ff97 100644 --- a/feature/detail/src/main/java/com/example/detail/DetailScreen.kt +++ b/feature/detail/src/main/java/com/example/detail/DetailScreen.kt @@ -45,7 +45,11 @@ import com.example.designsystem.component.snackbar.LocalShowSnackBar import com.example.designsystem.theme.DPlayTheme import com.example.designsystem.util.noRippleClickable import com.example.designsystem.util.roundedBackgroundWithPadding +import com.example.domain.model.BADGE +import com.example.navigation.MyPage +import com.example.navigation.MyPageTab import com.example.navigation.Navigator +import com.example.ui.controller.LocalModalController import kotlinx.coroutines.flow.collectLatest @Composable @@ -53,13 +57,14 @@ fun DetailRoute( postId: Long, navigator: Navigator, viewModel: DetailViewModel = hiltViewModel(), - date: String = "", + badge: BADGE? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val showSnackBar = LocalShowSnackBar.current + val modalController = LocalModalController.current LaunchedEffect(Unit) { - viewModel.handleIntent(DetailContract.DetailIntent.LoadData(postId = postId, date = date)) + viewModel.handleIntent(DetailContract.DetailIntent.LoadData(postId = postId, badge = badge)) } LaunchedEffect(viewModel.sideEffect) { @@ -78,7 +83,26 @@ fun DetailRoute( } is DetailContract.DetailSideEffect.NavigateToMyPage -> { - // TODO + navigator.navigateTo(destination = MyPage(initialTab = MyPageTab.BOOKMARKED)) + } + + is DetailContract.DetailSideEffect.ShowDeleteConfirmModal -> { + modalController.showWarningModal( + mainText = "정말 삭제하시겠어요?", + subText = "삭제된 글은 복구할 수 없어요", + leftButtonLabel = "취소", + rightButtonLabel = "삭제", + onLeftButtonClick = { + modalController.hideModal() + }, + onRightButtonClick = { + modalController.hideModal() + viewModel.handleIntent(DetailContract.DetailIntent.OnDeleteConfirmClick) + }, + onDismiss = { + modalController.hideModal() + }, + ) } } } @@ -172,9 +196,15 @@ private fun DetailScreen( onClick = onBookmarkClick, modifier = Modifier.align(Alignment.TopEnd), ) - if (state.isHost) { + state.badge?.let { badge -> + val chipType = + when (badge) { + BADGE.BEST -> DPlayChipType.BEST + BADGE.EDITOR -> DPlayChipType.EDITOR + BADGE.NEW -> DPlayChipType.NEW + } DPlayChip( - type = DPlayChipType.EDITOR, + type = chipType, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -272,7 +302,7 @@ private fun DetailScreen( .noRippleClickable { changeBottomSheetVisible(false) }, ) - if (state.isMyPost) { + if (state.isHost) { DPlayButtonBottomSheet( mainText = "삭제하기", subText = "취소하기", @@ -286,7 +316,7 @@ private fun DetailScreen( } else { DPlayReportBottomSheet( onCloseClick = { changeBottomSheetVisible(false) }, - onButtonClick = { changeBottomSheetVisible(false) }, + onButtonClick = { _ -> changeBottomSheetVisible(false) }, modifier = bottomSheetModifier, ) } diff --git a/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt b/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt index 5df0411f..41065b2f 100644 --- a/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt +++ b/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt @@ -2,16 +2,16 @@ package com.example.detail import androidx.lifecycle.viewModelScope import com.example.common.audio.AudioPlayer +import com.example.common.event.HomeRefreshTrigger import com.example.designsystem.component.snackbar.type.SnackBarType import com.example.detail.DetailContract.DetailSideEffect.NavigateToMyPage import com.example.detail.DetailContract.DetailSideEffect.ShowSnackBar +import com.example.domain.model.BADGE import com.example.domain.model.Like import com.example.domain.repository.PostRepository import com.example.domain.repository.TrackRepository -import com.example.domain.repository.UserRepository import com.example.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -25,7 +25,7 @@ class DetailViewModel private val postRepository: PostRepository, private val trackRepository: TrackRepository, private val audioPlayer: AudioPlayer, - private val userRepository: UserRepository, + private val homeRefreshTrigger: HomeRefreshTrigger, ) : BaseViewModel( DetailContract.DetailState(), ) { @@ -51,13 +51,18 @@ class DetailViewModel override fun handleIntent(intent: DetailContract.DetailIntent) { when (intent) { - is DetailContract.DetailIntent.LoadData -> loadData(intent.postId, intent.date) + is DetailContract.DetailIntent.LoadData -> loadData(intent.postId, intent.badge) is DetailContract.DetailIntent.OnBackButtonClick -> { setSideEffect(DetailContract.DetailSideEffect.NavigateBackStack) } is DetailContract.DetailIntent.OnBookmarkClick -> toggleBookmark() - is DetailContract.DetailIntent.OnDeleteClick -> deletePost() + is DetailContract.DetailIntent.OnDeleteClick -> { + changeBottomSheetVisible(visible = false) + setSideEffect(DetailContract.DetailSideEffect.ShowDeleteConfirmModal) + } + + is DetailContract.DetailIntent.OnDeleteConfirmClick -> deletePost() is DetailContract.DetailIntent.OnLikeClick -> toggleLike() is DetailContract.DetailIntent.OnMeatBallsClick -> { changeBottomSheetVisible(visible = true) @@ -77,11 +82,9 @@ class DetailViewModel private fun loadData( postId: Long, - date: String, + badge: BADGE?, ) { viewModelScope.launch { - val currentUserId = userRepository.getUser().first()?.id ?: 0L - postRepository .getPostDetail(postId = postId) .onSuccess { postDetail -> @@ -91,11 +94,11 @@ class DetailViewModel isScrapped = postDetail.isScrapped, content = postDetail.content, isHost = postDetail.isHost, + date = postDetail.displayDate, track = postDetail.track, writer = postDetail.writer, like = postDetail.like, - date = date, - currentUserId = currentUserId, + badge = badge, ) } }.onFailure { e -> @@ -167,10 +170,9 @@ class DetailViewModel postRepository .deletePost(postId = currentState.postId) .onSuccess { - changeBottomSheetVisible(visible = false) + homeRefreshTrigger.refresh() setSideEffect(DetailContract.DetailSideEffect.NavigateBackStack) }.onFailure { e -> - changeBottomSheetVisible(visible = false) Timber.e(e) } } diff --git a/feature/home/src/main/java/com/example/home/HomeContract.kt b/feature/home/src/main/java/com/example/home/HomeContract.kt index beb3de05..32a52ebd 100644 --- a/feature/home/src/main/java/com/example/home/HomeContract.kt +++ b/feature/home/src/main/java/com/example/home/HomeContract.kt @@ -1,6 +1,7 @@ package com.example.home import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.domain.model.BADGE import com.example.domain.model.DailyQuestion import com.example.domain.model.FeedItem import com.example.ui.base.BaseContract @@ -24,8 +25,6 @@ class HomeContract { ) : BaseContract.State sealed interface HomeIntent : BaseContract.Intent { - data object LoadHomeData : HomeIntent - data object OnRefreshClick : HomeIntent data class OnBookmarkClick( @@ -49,15 +48,20 @@ class HomeContract { data class OnCoverClick( val postId: Long, ) : HomeIntent + + data object OnLockedCoverClick : HomeIntent } sealed interface HomeSideEffect : BaseContract.SideEffect { + data object ShowLockedModal : HomeSideEffect + data class NavigateToWriterProfile( val writerUserId: Long, ) : HomeSideEffect data class NavigateToPostDetail( val postId: Long, + val badge: BADGE?, ) : HomeSideEffect data object NavigateToRecord : HomeSideEffect @@ -68,5 +72,7 @@ class HomeContract { ) : HomeSideEffect data object NavigateToMyPage : HomeSideEffect + + data object ScrollToFirstPage : HomeSideEffect } } diff --git a/feature/home/src/main/java/com/example/home/HomeScreen.kt b/feature/home/src/main/java/com/example/home/HomeScreen.kt index 4de492f7..6d4b8325 100644 --- a/feature/home/src/main/java/com/example/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/example/home/HomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -26,16 +28,21 @@ import com.example.designsystem.component.DPlayLargeCover import com.example.designsystem.component.DPlaySubjectItem import com.example.designsystem.component.DplayClickableIcon import com.example.designsystem.component.DplayLogoTopAppBar +import com.example.designsystem.component.button.DPlayBookmarkButton import com.example.designsystem.component.chip.DPlayChip import com.example.designsystem.component.chip.type.DPlayChipType import com.example.designsystem.component.snackbar.LocalShowSnackBar import com.example.designsystem.theme.DPlayTheme +import com.example.domain.model.BADGE import com.example.domain.model.FeedItem import com.example.navigation.Detail +import com.example.navigation.MyPage +import com.example.navigation.MyPageTab import com.example.navigation.Navigator import com.example.navigation.Record +import com.example.navigation.Search +import com.example.ui.controller.LocalModalController import kotlinx.coroutines.flow.collectLatest -import kotlin.math.absoluteValue @Composable fun HomeRoute( @@ -44,10 +51,12 @@ fun HomeRoute( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val showSnackBar = LocalShowSnackBar.current + val modalController = LocalModalController.current + val pagerState = rememberPagerState(pageCount = { state.feedItems.size }) - LaunchedEffect(Unit) { - viewModel.handleIntent(HomeContract.HomeIntent.LoadHomeData) - } + val lockedModalMainText = stringResource(R.string.recommend_prompt_modal_main_text) + val lockedModalSubText = stringResource(R.string.recommend_prompt_modal_sub_text) + val lockedModalButtonLabel = stringResource(R.string.recommend_prompt_modal_button_label) LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { @@ -60,7 +69,7 @@ fun HomeRoute( navigator.navigateTo( Detail( postId = it.postId, - date = state.todayQuestion.recordMMDD, + badge = it.badge, ), ) } @@ -74,13 +83,33 @@ fun HomeRoute( } is HomeContract.HomeSideEffect.NavigateToMyPage -> { - // TODO + navigator.navigateTo(destination = MyPage(initialTab = MyPageTab.BOOKMARKED)) + } + + is HomeContract.HomeSideEffect.ShowLockedModal -> { + modalController.showGraphicModal( + mainText = lockedModalMainText, + subText = lockedModalSubText, + buttonLabel = lockedModalButtonLabel, + onButtonClick = { + modalController.hideModal() + navigator.navigateTo(destination = Search) + }, + onDismiss = { + modalController.hideModal() + }, + ) + } + + is HomeContract.HomeSideEffect.ScrollToFirstPage -> { + pagerState.animateScrollToPage(0) } } } } HomeScreen( uiState = state, + pagerState = pagerState, onRefresh = { viewModel.handleIntent(HomeContract.HomeIntent.OnRefreshClick) }, @@ -102,12 +131,16 @@ fun HomeRoute( onListClick = { viewModel.handleIntent(HomeContract.HomeIntent.OnListClick) }, + onLockedCoverClick = { + viewModel.handleIntent(HomeContract.HomeIntent.OnLockedCoverClick) + }, ) } @Composable private fun HomeScreen( uiState: HomeContract.HomeState = HomeContract.HomeState(), + pagerState: PagerState, onRefresh: () -> Unit, onPostClick: (postId: Long) -> Unit, onBookmarkClick: (postId: Long) -> Unit, @@ -115,6 +148,7 @@ private fun HomeScreen( onLikeClick: (postId: Long) -> Unit, onWriterProfileClick: (Long) -> Unit, onListClick: () -> Unit, + onLockedCoverClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxSize(), @@ -145,12 +179,14 @@ private fun HomeScreen( ) Spacer(modifier = Modifier.height(32.dp)) HomePager( + pagerState = pagerState, feedItems = uiState.feedItems, onPostClick = onPostClick, onBookmarkClick = onBookmarkClick, onStreamClick = onStreamClick, onLikeClick = onLikeClick, onWriterProfileClick = onWriterProfileClick, + onLockedCoverClick = onLockedCoverClick, uiState = uiState, ) } @@ -158,25 +194,24 @@ private fun HomeScreen( @Composable private fun HomePager( + pagerState: PagerState, feedItems: List, onPostClick: (postId: Long) -> Unit, onBookmarkClick: (postId: Long) -> Unit, onStreamClick: (trackId: String) -> Unit, onLikeClick: (postId: Long) -> Unit, onWriterProfileClick: (Long) -> Unit, + onLockedCoverClick: () -> Unit, uiState: HomeContract.HomeState, ) { - val pagerState = rememberPagerState(pageCount = { feedItems.size }) - val currentItem = feedItems.getOrNull(pagerState.currentPage) val isCurrentPageLocked = uiState.locked && pagerState.currentPage >= 3 val currentChipType: DPlayChipType? = - currentItem?.let { - when { - it.badges.isPopular -> DPlayChipType.BEST - it.badges.isEditorPick -> DPlayChipType.EDITOR - it.badges.isNew -> DPlayChipType.NEW - else -> null + currentItem?.badge?.let { + when (it) { + BADGE.BEST -> DPlayChipType.BEST + BADGE.EDITOR -> DPlayChipType.EDITOR + BADGE.NEW -> DPlayChipType.NEW } } @@ -190,20 +225,11 @@ private fun HomePager( pageSpacing = 24.dp, ) { page -> val item = feedItems[page] - - val pageOffset = - ( - (pagerState.currentPage - page) + - pagerState.currentPageOffsetFraction - ).absoluteValue - - val isCenter = pageOffset < 0.2f val isLockedPage = uiState.locked && page >= 3 DPlayLargeCover( modifier = Modifier.fillMaxWidth(), isLocked = isLockedPage, - isBookmarkChecked = item.isScrapped, isLikeChecked = item.like.isLiked, likeCount = item.like.count, writerProfileImageUrl = item.writer.profileImg, @@ -212,11 +238,15 @@ private fun HomePager( musicImageUrl = item.track.coverImg, onStreamClick = { onStreamClick(item.track.trackId) }, onLikeClick = { onLikeClick(item.postId) }, - onBookmarkClick = { onBookmarkClick(item.postId) }, - onCoverClick = { if (!isLockedPage) onPostClick(item.postId) }, + onCoverClick = { + if (isLockedPage) { + onLockedCoverClick() + } else { + onPostClick(item.postId) + } + }, onWriterProfileClick = { onWriterProfileClick(item.writer.userId) }, isStreaming = uiState.streamingTrackId == item.track.trackId, - bookmarkIconVisible = isCenter, ) } } @@ -229,6 +259,19 @@ private fun HomePager( modifier = Modifier.align(Alignment.TopCenter), ) } + + currentItem + ?.takeIf { !isCurrentPageLocked } + ?.let { + DPlayBookmarkButton( + isMarked = it.isScrapped, + onClick = { onBookmarkClick(it.postId) }, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 52.dp, end = 40.dp), + ) + } } } @@ -240,6 +283,7 @@ private fun HomePager( private fun HomePreview() { DPlayTheme { HomeScreen( + pagerState = rememberPagerState(pageCount = { 0 }), onPostClick = {}, onBookmarkClick = {}, onStreamClick = {}, @@ -247,6 +291,7 @@ private fun HomePreview() { onWriterProfileClick = {}, onRefresh = {}, onListClick = {}, + onLockedCoverClick = {}, ) } } diff --git a/feature/home/src/main/java/com/example/home/HomeViewModel.kt b/feature/home/src/main/java/com/example/home/HomeViewModel.kt index c2193dd7..fe669996 100644 --- a/feature/home/src/main/java/com/example/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/example/home/HomeViewModel.kt @@ -2,8 +2,9 @@ package com.example.home import androidx.lifecycle.viewModelScope import com.example.common.audio.AudioPlayer +import com.example.common.event.HomeRefreshTrigger import com.example.designsystem.component.snackbar.type.SnackBarType -import com.example.domain.model.Badges +import com.example.domain.model.BADGE import com.example.domain.model.FeedItem import com.example.domain.model.Like import com.example.domain.model.Track @@ -24,14 +25,17 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( - val postRepository: PostRepository, + private val postRepository: PostRepository, private val trackRepository: TrackRepository, private val audioPlayer: AudioPlayer, + private val homeRefreshTrigger: HomeRefreshTrigger, ) : BaseViewModel( HomeContract.HomeState(), ) { init { observePlaybackState() + observeRefreshTrigger() + getTodayPosts() } private fun observePlaybackState() { @@ -50,9 +54,15 @@ class HomeViewModel }.launchIn(viewModelScope) } + private fun observeRefreshTrigger() { + homeRefreshTrigger.refreshEvent + .onEach { + getTodayPosts() + }.launchIn(viewModelScope) + } + override fun handleIntent(intent: HomeContract.HomeIntent) { when (intent) { - is HomeContract.HomeIntent.LoadHomeData -> getTodayPosts() is HomeContract.HomeIntent.OnBookmarkClick -> toggleBookmark(intent.postId) is HomeContract.HomeIntent.OnLikeClick -> toggleLike(intent.postId) is HomeContract.HomeIntent.OnRefreshClick -> refreshTodayPosts() @@ -66,7 +76,12 @@ class HomeViewModel } is HomeContract.HomeIntent.OnCoverClick -> { - setSideEffect(HomeContract.HomeSideEffect.NavigateToPostDetail(postId = intent.postId)) + val badge = currentState.feedItems.find { it.postId == intent.postId }?.badge + setSideEffect(HomeContract.HomeSideEffect.NavigateToPostDetail(postId = intent.postId, badge = badge)) + } + + is HomeContract.HomeIntent.OnLockedCoverClick -> { + setSideEffect(HomeContract.HomeSideEffect.ShowLockedModal) } } } @@ -77,7 +92,7 @@ class HomeViewModel .getTodayPosts() .onSuccess { data -> val feedItems = - if (data.locked && data.totalCount >= 4) { + if (data.locked) { data.todayPosts + lockedDummyFeedItem } else { data.todayPosts @@ -103,12 +118,7 @@ class HomeViewModel postId = -1L, isScrapped = false, content = "", - badges = - Badges( - isEditorPick = false, - isPopular = false, - isNew = false, - ), + badge = null, track = Track( trackId = "", @@ -163,6 +173,7 @@ class HomeViewModel private fun refreshTodayPosts() { getTodayPosts() + setSideEffect(HomeContract.HomeSideEffect.ScrollToFirstPage) } private fun toggleBookmark(postId: Long) { @@ -252,12 +263,7 @@ val dummyFeedItems = postId = 111, isScrapped = true, content = "그냥 좋아요 이 노래", - badges = - Badges( - isEditorPick = false, - isPopular = true, - isNew = true, - ), + badge = BADGE.BEST, track = Track( trackId = "apple:203948", @@ -282,12 +288,7 @@ val dummyFeedItems = postId = 112, isScrapped = false, content = "비 오는 날 꼭 듣는 노래에요", - badges = - Badges( - isEditorPick = true, - isPopular = false, - isNew = false, - ), + badge = BADGE.EDITOR, track = Track( trackId = "apple:204837", @@ -312,12 +313,7 @@ val dummyFeedItems = postId = 113, isScrapped = false, content = "출근길에 항상 듣습니다!", - badges = - Badges( - isEditorPick = false, - isPopular = false, - isNew = true, - ), + badge = BADGE.NEW, track = Track( trackId = "apple:204111", @@ -342,12 +338,7 @@ val dummyFeedItems = postId = 113, isScrapped = false, content = "출근길에 항상 듣습니다!", - badges = - Badges( - isEditorPick = false, - isPopular = false, - isNew = true, - ), + badge = BADGE.NEW, track = Track( trackId = "apple:204111", @@ -372,12 +363,7 @@ val dummyFeedItems = postId = 113, isScrapped = false, content = "출근길에 항상 듣습니다!", - badges = - Badges( - isEditorPick = false, - isPopular = false, - isNew = true, - ), + badge = BADGE.NEW, track = Track( trackId = "apple:204111", @@ -402,12 +388,7 @@ val dummyFeedItems = postId = 113, isScrapped = false, content = "출근길에 항상 듣습니다!", - badges = - Badges( - isEditorPick = false, - isPopular = false, - isNew = true, - ), + badge = BADGE.NEW, track = Track( trackId = "apple:204111", diff --git a/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt b/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt index ceab4a23..dd5a5f25 100644 --- a/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt +++ b/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt @@ -60,7 +60,7 @@ fun BottomNavigationBar( ) { topLevelRouteList.forEach { tab -> BottomNavigationItem( - isSelected = currentTab == tab, + isSelected = currentTab?.let { it::class == tab::class } ?: false, tab = tab, onBottomNavigationItemClick = { onBottomNavigationItemClick(it) }, ) @@ -107,7 +107,7 @@ fun BottomNavigationBarPreview() { DPlayTheme { BottomNavigationBar( isVisible = true, - topLevelRouteList = persistentListOf(Home, MyPage), + topLevelRouteList = persistentListOf(Home, MyPage()), currentTab = currentTab, onBottomNavigationItemClick = { currentTab = it diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt index 5cb8c251..afbc9ffd 100644 --- a/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt @@ -19,8 +19,11 @@ object MyPageNavigationModule { navigator: Navigator, ): EntryProviderScope.() -> Unit = { - entry { - MyPageRoute(navigator = navigator) + entry { myPage -> + MyPageRoute( + navigator = navigator, + initialTab = myPage.initialTab, + ) } } } diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt index 740bdb68..b34bacc6 100644 --- a/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,6 +59,7 @@ import com.example.designsystem.theme.DPlayTheme import com.example.designsystem.util.noRippleClickable import com.example.navigation.Detail import com.example.navigation.EditProfile +import com.example.navigation.MyPageTab import com.example.navigation.Navigator import com.example.navigation.Setting import com.example.ui.controller.LocalBottomNavigationController @@ -71,9 +73,23 @@ import kotlinx.coroutines.flow.collectLatest fun MyPageRoute( navigator: Navigator, modifier: Modifier = Modifier, + initialTab: MyPageTab = MyPageTab.REGISTERED, viewModel: MyPageViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + var hasAppliedInitialTab by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (!hasAppliedInitialTab) { + val tabIndex = + when (initialTab) { + MyPageTab.REGISTERED -> 0 + MyPageTab.BOOKMARKED -> 1 + } + viewModel.handleIntent(MyPageContract.MyPageIntent.OnTabClick(tabIndex)) + hasAppliedInitialTab = true + } + } val registeredTracks = viewModel.registeredTracks.collectAsLazyPagingItems() val scrappedTracks = viewModel.scrappedTracks.collectAsLazyPagingItems() diff --git a/feature/record/src/main/java/com/example/record/RecordListScreen.kt b/feature/record/src/main/java/com/example/record/RecordListScreen.kt index efb3331b..3891a74e 100644 --- a/feature/record/src/main/java/com/example/record/RecordListScreen.kt +++ b/feature/record/src/main/java/com/example/record/RecordListScreen.kt @@ -21,6 +21,7 @@ import com.example.designsystem.component.DPlayMusicListItem import com.example.designsystem.component.DPlaySubjectItem import com.example.designsystem.component.DplayLeftIconTitleTopAppBar import com.example.designsystem.theme.DPlayTheme +import com.example.domain.model.BADGE import com.example.domain.model.FeedItem import com.example.ui.emptyLazyPagingItems @@ -70,8 +71,7 @@ fun RecordListScreen( musicName = item.track.songTitle, musicArtistName = item.track.artistName, musicContent = item.content, - onMoreClick = {}, - isEditorPick = item.badges.isEditorPick, + isEditorPick = (item.badge == BADGE.EDITOR), onClick = { onMusicClick(item.postId) }, ) }