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,
- )
- }
}
}