diff --git a/core/designsystem/src/main/res/drawable/icon_memo_filled.xml b/core/designsystem/src/main/res/drawable/icon_memo_filled.xml new file mode 100644 index 00000000..6d098d03 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_memo_filled.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_memo_stroked.xml b/core/designsystem/src/main/res/drawable/icon_memo_stroked.xml new file mode 100644 index 00000000..efc422c5 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_memo_stroked.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt index 6d1b3d6a..e382f6da 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt @@ -1,8 +1,6 @@ package com.neki.android.feature.archive.impl.photo_detail import com.neki.android.core.model.Photo -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentMapOf enum class MemoMode { Closed, @@ -18,12 +16,10 @@ data class PhotoDetailState( val isShowDeleteDialog: Boolean = false, val isShowOptionPopup: Boolean = false, val memo: String = "", - val memoModes: ImmutableMap = persistentMapOf(), + val memoMode: MemoMode = MemoMode.Closed, ) { val currentIndex get() = if (photos.isEmpty()) 0 else currentPage.coerceIn(0, photos.lastIndex) val photo: Photo get() = photos.getOrElse(currentIndex) { Photo() } - val currentMemoMode: MemoMode get() = memoModes[photo.id] ?: MemoMode.Closed - fun memoModeOf(photoId: Long): MemoMode = memoModes[photoId] ?: MemoMode.Closed } sealed interface PhotoDetailIntent { @@ -37,6 +33,7 @@ sealed interface PhotoDetailIntent { data object ClickLeftPhoto : PhotoDetailIntent data object ClickRightPhoto : PhotoDetailIntent data class PageChanged(val page: Int) : PhotoDetailIntent + data object PageScrollStarted : PhotoDetailIntent // ActionBar Intent data object ClickDownloadIcon : PhotoDetailIntent diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt index c92901c6..50f328c0 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt @@ -1,10 +1,23 @@ package com.neki.android.feature.archive.impl.photo_detail +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -16,17 +29,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.foundation.layout.width -import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.topbar.BackTitleOptionTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo @@ -37,10 +52,12 @@ import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.archive.api.PhotoDetailResult import com.neki.android.feature.archive.impl.component.DeletePhotoDialog +import com.neki.android.feature.archive.impl.photo_detail.component.MemoTextField import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailActionBar import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailImageItem import com.neki.android.feature.archive.impl.util.ImageDownloader import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import timber.log.Timber @@ -66,6 +83,14 @@ internal fun PhotoDetailRoute( } } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.isScrollInProgress } + .filter { it } + .collect { + viewModel.store.onIntent(PhotoDetailIntent.PageScrollStarted) + } + } + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { PhotoDetailSideEffect.NavigateBack -> navigateBack() @@ -111,8 +136,6 @@ internal fun PhotoDetailScreen( onIntent: (PhotoDetailIntent) -> Unit = {}, pagerState: PagerState = rememberPagerState { uiState.photos.size.coerceAtLeast(1) }, ) { - val isMemoActive = uiState.currentMemoMode == MemoMode.Expanded || - uiState.currentMemoMode == MemoMode.Editing val density = LocalDensity.current var actionBarHeightDp by remember { mutableStateOf(0.dp) } @@ -127,46 +150,24 @@ internal fun PhotoDetailScreen( onClickIcon = { onIntent(PhotoDetailIntent.ClickOptionIcon) }, ) - HorizontalPager( + PhotoDetailPagerArea( modifier = Modifier .fillMaxWidth() .weight(1f) .clipToBounds(), - state = pagerState, - beyondViewportPageCount = 1, - userScrollEnabled = !isMemoActive, - ) { page -> - val index = if (uiState.photos.isEmpty()) 0 else page.coerceIn(0, uiState.photos.lastIndex) - - val photo = uiState.photos.getOrNull(index) - val pageMemoMode = uiState.memoModeOf(photo?.id ?: 0L) - val pageMemo = if (index == uiState.currentIndex) uiState.memo - else photo?.memo.orEmpty() - - PhotoDetailImageItem( - imageUrl = photo?.imageUrl, - memo = pageMemo, - memoMode = pageMemoMode, - actionBarHeight = actionBarHeightDp, - isScrollInProgress = pagerState.isScrollInProgress, - isTapEnabled = pageMemoMode != MemoMode.Expanded && pageMemoMode != MemoMode.Editing, - onClickLeft = { onIntent(PhotoDetailIntent.ClickLeftPhoto) }, - onClickRight = { onIntent(PhotoDetailIntent.ClickRightPhoto) }, - onClickMemoMore = { onIntent(PhotoDetailIntent.ClickMemoMore) }, - onClickMemoText = { onIntent(PhotoDetailIntent.ClickMemoText) }, - onClickMemoFold = { onIntent(PhotoDetailIntent.ClickMemoFold) }, - onClickMemoCancel = { onIntent(PhotoDetailIntent.ClickMemoCancel) }, - onClickMemoDone = { onIntent(PhotoDetailIntent.ClickMemoDone(it)) }, - onMemoTextChanged = { onIntent(PhotoDetailIntent.MemoTextChanged(it)) }, - ) - } + uiState = uiState, + actionBarHeightDp = actionBarHeightDp, + pagerState = pagerState, + onIntent = onIntent, + ) - if (uiState.currentMemoMode != MemoMode.Editing) { + if (uiState.memoMode != MemoMode.Editing) { PhotoDetailActionBar( modifier = Modifier.onSizeChanged { size -> actionBarHeightDp = with(density) { size.height.toDp() } }, isFavorite = uiState.photo.isFavorite, + hasMemo = uiState.photo.memo.isNotEmpty(), onClickDownload = { onIntent(PhotoDetailIntent.ClickDownloadIcon) }, onClickFavorite = { onIntent(PhotoDetailIntent.ClickFavoriteIcon) }, onClickMemo = { onIntent(PhotoDetailIntent.ClickMemoIcon) }, @@ -204,6 +205,78 @@ internal fun PhotoDetailScreen( } } +@Composable +private fun PhotoDetailPagerArea( + modifier: Modifier, + uiState: PhotoDetailState, + actionBarHeightDp: Dp, + pagerState: PagerState, + onIntent: (PhotoDetailIntent) -> Unit, +) { + val isMemoActive = uiState.memoMode == MemoMode.Expanded || + uiState.memoMode == MemoMode.Editing + + Box(modifier = modifier) { + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(bottom = if (uiState.memoMode == MemoMode.Editing) actionBarHeightDp else 0.dp), + state = pagerState, + beyondViewportPageCount = 1, + userScrollEnabled = !isMemoActive, + ) { page -> + val index = if (uiState.photos.isEmpty()) 0 else page.coerceIn(0, uiState.photos.lastIndex) + val photo = uiState.photos.getOrNull(index) + + PhotoDetailImageItem( + imageUrl = photo?.imageUrl, + isScrollInProgress = pagerState.isScrollInProgress, + isTapEnabled = !isMemoActive, + onClickLeft = { onIntent(PhotoDetailIntent.ClickLeftPhoto) }, + onClickRight = { onIntent(PhotoDetailIntent.ClickRightPhoto) }, + ) + } + + AnimatedVisibility( + visible = isMemoActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x80202227)) + .noRippleClickableSingle { onIntent(PhotoDetailIntent.ClickMemoFold) }, + ) + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomCenter) + .then( + if (uiState.memoMode == MemoMode.Editing) Modifier.imePadding() + else Modifier.windowInsetsPadding( + WindowInsets.ime.exclude(WindowInsets(bottom = actionBarHeightDp)), + ), + ), + visible = uiState.memoMode != MemoMode.Closed, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + MemoTextField( + memo = uiState.memo, + memoMode = uiState.memoMode, + onClickMemoMore = { onIntent(PhotoDetailIntent.ClickMemoMore) }, + onClickMemoText = { onIntent(PhotoDetailIntent.ClickMemoText) }, + onClickMemoFold = { onIntent(PhotoDetailIntent.ClickMemoFold) }, + onClickMemoCancel = { onIntent(PhotoDetailIntent.ClickMemoCancel) }, + onClickMemoDone = { onIntent(PhotoDetailIntent.ClickMemoDone(it)) }, + onMemoTextChanged = { onIntent(PhotoDetailIntent.MemoTextChanged(it)) }, + ) + } + } +} + @DevicePreview @Composable private fun PhotoDetailScreenPreview() { diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt index 0570167f..535631ab 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt @@ -11,7 +11,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow @@ -111,6 +110,8 @@ class PhotoDetailViewModel @AssistedInject constructor( preloadIfNeeded(reduce) } + PhotoDetailIntent.PageScrollStarted -> reduce { copy(memoMode = MemoMode.Closed) } + // ActionBar Intent PhotoDetailIntent.ClickDownloadIcon -> postSideEffect(PhotoDetailSideEffect.DownloadImage(state.photo.imageUrl)) PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce) @@ -132,40 +133,36 @@ class PhotoDetailViewModel @AssistedInject constructor( // Memo Intent is PhotoDetailIntent.MemoTextChanged -> reduce { copy(memo = intent.text) } PhotoDetailIntent.ClickMemoIcon -> reduce { - val photoId = photo.id - val current = memoModeOf(photoId) - val newMode = if (current == MemoMode.Closed) MemoMode.Preview else MemoMode.Closed - copy(memoModes = (memoModes + (photoId to newMode)).toImmutableMap()) + copy(memoMode = if (memoMode == MemoMode.Closed) MemoMode.Preview else MemoMode.Closed) } PhotoDetailIntent.ClickMemoMore -> reduce { - copy(memoModes = (memoModes + (photo.id to MemoMode.Expanded)).toImmutableMap()) + copy(memoMode = MemoMode.Expanded) } PhotoDetailIntent.ClickMemoText -> reduce { - copy(memoModes = (memoModes + (photo.id to MemoMode.Editing)).toImmutableMap()) + copy(memoMode = MemoMode.Editing) } PhotoDetailIntent.ClickMemoFold -> reduce { copy( memo = photo.memo, - memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(), + memoMode = MemoMode.Preview, ) } PhotoDetailIntent.ClickMemoCancel -> reduce { copy( memo = photo.memo, - memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(), + memoMode = MemoMode.Preview, ) } is PhotoDetailIntent.ClickMemoDone -> { - val photoId = state.photo.id reduce { copy( memo = intent.memo, - memoModes = (memoModes + (photoId to MemoMode.Preview)).toImmutableMap(), + memoMode = MemoMode.Preview, ) } saveMemo(state.copy(memo = intent.memo), reduce, postSideEffect) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt index d9a60059..5a680435 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt @@ -19,6 +19,7 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme @Composable internal fun PhotoDetailActionBar( isFavorite: Boolean, + hasMemo: Boolean, modifier: Modifier = Modifier, onClickDownload: () -> Unit = {}, onClickFavorite: () -> Unit = {}, @@ -61,7 +62,10 @@ internal fun PhotoDetailActionBar( ) { Icon( modifier = Modifier.size(28.dp), - imageVector = ImageVector.vectorResource(R.drawable.icon_memo), + imageVector = ImageVector.vectorResource( + if (hasMemo) R.drawable.icon_memo_filled + else R.drawable.icon_memo_stroked, + ), contentDescription = null, tint = NekiTheme.colorScheme.gray700, ) @@ -88,6 +92,7 @@ private fun PhotoDetailActionBarClosedPreview() { NekiTheme { PhotoDetailActionBar( isFavorite = false, + hasMemo = false, ) } } @@ -98,6 +103,7 @@ private fun PhotoDetailActionBarMemoActivePreview() { NekiTheme { PhotoDetailActionBar( isFavorite = true, + hasMemo = true, ) } } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailImageItem.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailImageItem.kt index 9c0725c2..a59423e7 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailImageItem.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailImageItem.kt @@ -1,35 +1,17 @@ package com.neki.android.feature.archive.impl.photo_detail.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import com.neki.android.core.designsystem.modifier.noRippleClickableSingle -import com.neki.android.feature.archive.impl.photo_detail.MemoMode import net.engawapg.lib.zoomable.ScrollGesturePropagation import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -37,22 +19,11 @@ import net.engawapg.lib.zoomable.zoomable @Composable internal fun PhotoDetailImageItem( imageUrl: String?, - memo: String, - memoMode: MemoMode, - actionBarHeight: Dp = 0.dp, isScrollInProgress: Boolean, isTapEnabled: Boolean, onClickLeft: () -> Unit, onClickRight: () -> Unit, - onClickMemoMore: () -> Unit, - onClickMemoText: () -> Unit, - onClickMemoFold: () -> Unit, - onClickMemoCancel: () -> Unit, - onClickMemoDone: (String) -> Unit, - onMemoTextChanged: (String) -> Unit, ) { - val isMemoActive = memoMode == MemoMode.Expanded || memoMode == MemoMode.Editing - val bottomPadding = if (memoMode == MemoMode.Editing) actionBarHeight else 0.dp val zoomState = rememberZoomState() var contentWidth by remember { mutableIntStateOf(0) } @@ -62,7 +33,6 @@ internal fun PhotoDetailImageItem( AsyncImage( modifier = Modifier .fillMaxSize() - .padding(bottom = bottomPadding) .onSizeChanged { contentWidth = it.width } .zoomable( zoomState = zoomState, @@ -86,45 +56,5 @@ internal fun PhotoDetailImageItem( contentScale = ContentScale.Fit, onSuccess = { state -> zoomState.setContentSize(state.painter.intrinsicSize) }, ) - - // dim 오버레이 - AnimatedVisibility( - visible = isMemoActive, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0x80202227)) - .noRippleClickableSingle { onClickMemoFold() }, - ) - } - - // 메모 텍스트 영역 - AnimatedVisibility( - modifier = Modifier - .align(Alignment.BottomCenter) - .then( - if (memoMode == MemoMode.Editing) Modifier.imePadding() - else Modifier.windowInsetsPadding( - WindowInsets.ime.exclude(WindowInsets(bottom = actionBarHeight)), - ), - ), - visible = memoMode != MemoMode.Closed, - enter = expandVertically(expandFrom = Alignment.Top), - exit = shrinkVertically(shrinkTowards = Alignment.Top), - ) { - MemoTextField( - memo = memo, - memoMode = memoMode, - onClickMemoMore = onClickMemoMore, - onClickMemoText = onClickMemoText, - onClickMemoFold = onClickMemoFold, - onClickMemoCancel = onClickMemoCancel, - onClickMemoDone = onClickMemoDone, - onMemoTextChanged = onMemoTextChanged, - ) - } } }