diff --git a/app/src/main/java/com/neki/android/app/main/MainScreen.kt b/app/src/main/java/com/neki/android/app/main/MainScreen.kt index 4b401960..9cabf275 100644 --- a/app/src/main/java/com/neki/android/app/main/MainScreen.kt +++ b/app/src/main/java/com/neki/android/app/main/MainScreen.kt @@ -33,7 +33,7 @@ import com.neki.android.core.ui.component.LoadingDialog import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.archive.api.ArchiveNavKey -import com.neki.android.feature.archive.api.ArchiveResult +import com.neki.android.feature.archive.api.PhotoUploadedResult import com.neki.android.feature.map.api.MapNavKey import com.neki.android.feature.mypage.api.MyPageNavKey import com.neki.android.feature.photo_upload.api.PhotoUploadNavKey @@ -86,7 +86,7 @@ fun MainRoute( is MainSideEffect.NavigateToUploadAlbumWithGallery -> navigateToUploadAlbumWithGallery(sideEffect.uriStrings) is MainSideEffect.NavigateToUploadAlbumWithQRScan -> navigateToUploadAlbumWithQRScan(sideEffect.imageUrl) is MainSideEffect.ShowToast -> nekiToast.showToast(sideEffect.message) - MainSideEffect.RefreshArchive -> resultBus.sendResult(result = ArchiveResult.PhotoUploaded) + MainSideEffect.RefreshArchive -> resultBus.sendResult(result = PhotoUploadedResult, allowDuplicate = false) } } diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt index 0135649f..b34526b9 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt @@ -25,6 +25,8 @@ interface PhotoRepository { suspend fun updateFavorite(photoId: Long, favorite: Boolean): Result + suspend fun updateMemo(photoId: Long, memo: String): Result + suspend fun getPhotosPage( folderId: Long? = null, page: Int = 0, diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt index d084a7f5..457ea17c 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt @@ -3,6 +3,7 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.request.DeletePhotoRequest import com.neki.android.core.data.remote.model.request.RegisterPhotoRequest import com.neki.android.core.data.remote.model.request.UpdateFavoriteRequest +import com.neki.android.core.data.remote.model.request.UpdateMemoRequest import com.neki.android.core.data.remote.model.response.BasicNullableResponse import com.neki.android.core.data.remote.model.response.BasicResponse import com.neki.android.core.data.remote.model.response.FavoriteSummaryResponse @@ -15,6 +16,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.patch import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody import javax.inject.Inject @@ -53,6 +55,13 @@ class PhotoService @Inject constructor( }.body() } + // 메모 수정 + suspend fun updateMemo(photoId: Long, memo: String): BasicNullableResponse { + return client.put("/api/photos/$photoId") { + setBody(UpdateMemoRequest(memo)) + }.body() + } + // 즐겨찾는 앨범 조회 suspend fun getFavoritePhotos( page: Int = 0, diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateMemoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateMemoRequest.kt new file mode 100644 index 00000000..55a07d76 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateMemoRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateMemoRequest( + @SerialName("memo") val memo: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt index 2eeecdaf..b7b591cc 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt @@ -20,6 +20,7 @@ data class PhotoResponse( @SerialName("width") val width: Int? = null, @SerialName("height") val height: Int? = null, @SerialName("createdAt") val createdAt: String, + @SerialName("memo") val memo: String? = null, ) { internal fun toModel() = Photo( id = photoId, @@ -29,6 +30,7 @@ data class PhotoResponse( height = height, date = createdAt.toFormattedDate(), contentType = ContentType.fromString(contentType), + memo = memo.orEmpty(), ) } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt index 88ab46c9..427ddf54 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt @@ -65,6 +65,10 @@ class PhotoRepositoryImpl @Inject constructor( photoService.updateFavorite(photoId, favorite) } + override suspend fun updateMemo(photoId: Long, memo: String): Result = runSuspendCatching { + photoService.updateMemo(photoId, memo) + } + override suspend fun getPhotosPage( folderId: Long?, page: Int, diff --git a/core/designsystem/src/main/res/drawable/icon_memo.xml b/core/designsystem/src/main/res/drawable/icon_memo.xml new file mode 100644 index 00000000..a951a68e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_memo.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/core/model/src/main/java/com/neki/android/core/model/Photo.kt b/core/model/src/main/java/com/neki/android/core/model/Photo.kt index 2ab8e5ff..befcc51a 100644 --- a/core/model/src/main/java/com/neki/android/core/model/Photo.kt +++ b/core/model/src/main/java/com/neki/android/core/model/Photo.kt @@ -13,4 +13,5 @@ data class Photo( val width: Int? = null, val height: Int? = null, val contentType: ContentType = ContentType.JPEG, + val memo: String = "", ) diff --git a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt index 0ae9a4e7..1d7759ee 100644 --- a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt @@ -1,11 +1,13 @@ package com.neki.android.feature.archive.api -sealed interface ArchiveResult { - data class PhotoDeleted(val photoId: List) : ArchiveResult { - constructor(photoId: Long) : this(listOf(photoId)) - } +sealed interface ArchiveResult - data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : ArchiveResult +data object PhotoDetailResult : ArchiveResult - data object PhotoUploaded : ArchiveResult -} +data object AlbumDetailResult : ArchiveResult + +data object AllPhotoResult : ArchiveResult + +data object AllAlbumResult : ArchiveResult + +data object PhotoUploadedResult : ArchiveResult diff --git a/feature/archive/impl/build.gradle.kts b/feature/archive/impl/build.gradle.kts index 7540e385..be511b3f 100644 --- a/feature/archive/impl/build.gradle.kts +++ b/feature/archive/impl/build.gradle.kts @@ -12,4 +12,5 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.paging.compose) + implementation(libs.zoomable) } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt index c61aa6ee..1372eb74 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt @@ -53,6 +53,9 @@ sealed interface AllAlbumIntent { data object DismissDeleteAlbumBottomSheet : AllAlbumIntent data class SelectDeleteOption(val option: AlbumDeleteOption) : AllAlbumIntent data object ClickDeleteConfirmButton : AllAlbumIntent + + // Result Intent + data object RefreshAlbums : AllAlbumIntent } sealed interface AllAlbumSideEffect { @@ -60,4 +63,5 @@ sealed interface AllAlbumSideEffect { data class NavigateToFavoriteAlbum(val albumId: Long) : AllAlbumSideEffect data class NavigateToAlbumDetail(val albumId: Long, val title: String) : AllAlbumSideEffect data class ShowToastMessage(val message: String) : AllAlbumSideEffect + data object NotifyResult : AllAlbumSideEffect } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt index 7b16f55c..92d2f007 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.DevicePreview import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.feature.archive.api.AllAlbumResult import com.neki.android.core.ui.component.AlbumRowComponent import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet import com.neki.android.core.ui.component.FavoriteAlbumRowComponent @@ -41,6 +43,7 @@ internal fun AllAlbumRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { @@ -50,6 +53,9 @@ internal fun AllAlbumRoute( is AllAlbumSideEffect.ShowToastMessage -> { nekiToast.showToast(text = sideEffect.message) } + AllAlbumSideEffect.NotifyResult -> { + resultEventBus.sendResult(result = AllAlbumResult, allowDuplicate = false) + } } } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt index 98058dea..feae3d1f 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt @@ -76,6 +76,9 @@ class AllAlbumViewModel @Inject constructor( AllAlbumIntent.DismissDeleteAlbumBottomSheet -> reduce { copy(isShowDeleteAlbumBottomSheet = false) } is AllAlbumIntent.SelectDeleteOption -> reduce { copy(selectedDeleteOption = intent.option) } AllAlbumIntent.ClickDeleteConfirmButton -> handleDeleteConfirm(state, reduce, postSideEffect) + + // Result Intent + AllAlbumIntent.RefreshAlbums -> fetchInitialData(reduce) } } @@ -178,6 +181,7 @@ class AllAlbumViewModel @Inject constructor( .onSuccess { fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + postSideEffect(AllAlbumSideEffect.NotifyResult) } .onFailure { e -> postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) @@ -200,6 +204,7 @@ class AllAlbumViewModel @Inject constructor( .onSuccess { fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 삭제했어요")) + postSideEffect(AllAlbumSideEffect.NotifyResult) } .onFailure { e -> Timber.e(e, "사진 삭제 실패") diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt index c53c0c69..8252ca33 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt @@ -71,9 +71,8 @@ sealed interface AlbumDetailIntent { data object ClickRenameBottomSheetConfirmButton : AlbumDetailIntent // Result Intent - data class PhotoDeleted(val photoIds: List) : AlbumDetailIntent + data object RefreshPhotos : AlbumDetailIntent data class ClickFavoriteIcon(val photo: Photo) : AlbumDetailIntent - data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AlbumDetailIntent } sealed interface AlbumDetailSideEffect { @@ -83,4 +82,5 @@ sealed interface AlbumDetailSideEffect { data class DownloadImages(val imageUrls: List) : AlbumDetailSideEffect data object OpenGallery : AlbumDetailSideEffect data object RefreshPhotos : AlbumDetailSideEffect + data object NotifyResult : AlbumDetailSideEffect } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt index f0fa39ae..48bab511 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt @@ -36,6 +36,8 @@ import androidx.paging.compose.itemKey import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo import com.neki.android.core.model.SortOrder +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.feature.archive.api.AlbumDetailResult import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet import com.neki.android.feature.archive.api.ArchiveNavKey import com.neki.android.core.ui.component.LoadingDialog @@ -69,6 +71,7 @@ internal fun AlbumDetailRoute( val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(10)) { uris -> if (uris.isNotEmpty()) { viewModel.store.onIntent(AlbumDetailIntent.SelectGalleryImage(uris)) @@ -109,6 +112,10 @@ internal fun AlbumDetailRoute( AlbumDetailSideEffect.OpenGallery -> photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) AlbumDetailSideEffect.RefreshPhotos -> pagingItems.refresh() + + AlbumDetailSideEffect.NotifyResult -> { + resultEventBus.sendResult(result = AlbumDetailResult, allowDuplicate = false) + } } } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt index 1683e5e5..62a57b80 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt @@ -143,8 +143,10 @@ class AlbumDetailViewModel @AssistedInject constructor( is AlbumDetailIntent.SelectGalleryImage -> uploadMultipleImages(intent.uris, reduce, postSideEffect) // Result Intent - is AlbumDetailIntent.PhotoDeleted -> { - deletedPhotoIds.update { it + intent.photoIds.toSet() } + AlbumDetailIntent.RefreshPhotos -> { + deletedPhotoIds.value = emptySet() + updatedFavorites.value = emptyMap() + postSideEffect(AlbumDetailSideEffect.RefreshPhotos) } is AlbumDetailIntent.ClickFavoriteIcon -> { @@ -160,10 +162,6 @@ class AlbumDetailViewModel @AssistedInject constructor( } } - is AlbumDetailIntent.FavoriteChanged -> { - updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } - } - AlbumDetailIntent.DismissRenameBottomSheet -> reduce { copy(isShowRenameAlbumBottomSheet = false, renameAlbumTextState = TextFieldState()) } @@ -196,6 +194,7 @@ class AlbumDetailViewModel @AssistedInject constructor( ) } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("앨범 이름을 변경했어요")) + postSideEffect(AlbumDetailSideEffect.NotifyResult) }.onFailure { e -> Timber.e(e) reduce { copy(isLoading = false) } @@ -220,6 +219,7 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce { copy(isLoading = false) } postSideEffect(AlbumDetailSideEffect.RefreshPhotos) postSideEffect(AlbumDetailSideEffect.ShowToastMessage("새로운 사진을 추가했어요")) + postSideEffect(AlbumDetailSideEffect.NotifyResult) }.onFailure { e -> Timber.e(e) postSideEffect(AlbumDetailSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) @@ -322,6 +322,7 @@ class AlbumDetailViewModel @AssistedInject constructor( ) } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + postSideEffect(AlbumDetailSideEffect.NotifyResult) } .onFailure { e -> Timber.e(e) @@ -364,6 +365,7 @@ class AlbumDetailViewModel @AssistedInject constructor( ) } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + postSideEffect(AlbumDetailSideEffect.NotifyResult) } .onFailure { e -> Timber.e(e) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt index 596b9743..4ea1ee38 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt @@ -18,7 +18,7 @@ data class ArchiveMainState( sealed interface ArchiveMainIntent { data object EnterArchiveMainScreen : ArchiveMainIntent - data object RefreshArchiveMainPhotos : ArchiveMainIntent + data object RefreshArchiveMain : ArchiveMainIntent data object ClickScreen : ArchiveMainIntent data object ClickGoToTopButton : ArchiveMainIntent diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt index 5c823aa5..a45b94eb 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt @@ -45,7 +45,13 @@ class ArchiveMainViewModel @Inject constructor( if (intent != ArchiveMainIntent.EnterArchiveMainScreen) reduce { copy(isFirstEntered = false) } when (intent) { ArchiveMainIntent.EnterArchiveMainScreen -> fetchInitialData(reduce) - ArchiveMainIntent.RefreshArchiveMainPhotos -> viewModelScope.launch { fetchPhotos(reduce) } + ArchiveMainIntent.RefreshArchiveMain -> viewModelScope.launch { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchPhotos(reduce) }, + async { fetchFolders(reduce) }, + ) + } ArchiveMainIntent.ClickScreen -> reduce { copy(isFirstEntered = false) } ArchiveMainIntent.ClickGoToTopButton -> postSideEffect(ArchiveMainSideEffect.ScrollToTop) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt index f0fb0f4d..d874dc8e 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt @@ -7,13 +7,19 @@ import com.neki.android.core.navigation.main.EntryProviderInstaller import com.neki.android.core.navigation.main.MainNavigator import com.neki.android.core.navigation.result.LocalResultEventBus import com.neki.android.core.navigation.result.ResultEffect +import com.neki.android.feature.archive.api.AlbumDetailResult +import com.neki.android.feature.archive.api.AllAlbumResult +import com.neki.android.feature.archive.api.AllPhotoResult import com.neki.android.feature.archive.api.ArchiveNavKey -import com.neki.android.feature.archive.api.ArchiveResult +import com.neki.android.feature.archive.api.PhotoDetailResult +import com.neki.android.feature.archive.api.PhotoUploadedResult import com.neki.android.feature.archive.api.navigateToAlbumDetail import com.neki.android.feature.archive.api.navigateToAllAlbum import com.neki.android.feature.archive.api.navigateToAllPhoto import com.neki.android.feature.archive.api.navigateToPhotoDetail +import com.neki.android.feature.archive.impl.album.AllAlbumIntent import com.neki.android.feature.archive.impl.album.AllAlbumRoute +import com.neki.android.feature.archive.impl.album.AllAlbumViewModel import com.neki.android.feature.archive.impl.album_detail.AlbumDetailIntent import com.neki.android.feature.archive.impl.album_detail.AlbumDetailRoute import com.neki.android.feature.archive.impl.album_detail.AlbumDetailViewModel @@ -47,11 +53,21 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() - ResultEffect(resultBus) { result -> - when (result) { - is ArchiveResult.FavoriteChanged, is ArchiveResult.PhotoDeleted, ArchiveResult.PhotoUploaded -> - viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMainPhotos) - } + + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMain) + } + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMain) + } + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMain) + } + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMain) + } + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMain) } ArchiveMainRoute( @@ -73,18 +89,9 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() - ResultEffect(resultBus) { result -> - when (result) { - is ArchiveResult.FavoriteChanged -> { - viewModel.store.onIntent(AllPhotoIntent.FavoriteChanged(result.photoId, result.isFavorite)) - } - - is ArchiveResult.PhotoDeleted -> { - viewModel.store.onIntent(AllPhotoIntent.PhotoDeleted(result.photoId)) - } - - else -> {} - } + ResultEffect(resultBus) { + viewModel.store.onIntent(AllPhotoIntent.RefreshPhotos) + resultBus.sendResult(result = AllPhotoResult, allowDuplicate = false) } AllPhotoRoute( @@ -95,7 +102,16 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { } entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { + viewModel.store.onIntent(AllAlbumIntent.RefreshAlbums) + resultBus.sendResult(result = AllAlbumResult, allowDuplicate = false) + } + AllAlbumRoute( + viewModel = viewModel, navigateBack = navigator::goBack, navigateToFavoriteAlbum = { id -> navigator.navigateToAlbumDetail(id = id, isFavorite = true) @@ -112,18 +128,9 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { creationCallback = { factory -> factory.create(key.albumId, key.title, key.isFavorite) }, ) - ResultEffect(resultBus) { result -> - when (result) { - is ArchiveResult.FavoriteChanged -> { - viewModel.store.onIntent(AlbumDetailIntent.FavoriteChanged(result.photoId, result.isFavorite)) - } - - is ArchiveResult.PhotoDeleted -> { - viewModel.store.onIntent(AlbumDetailIntent.PhotoDeleted(result.photoId)) - } - - else -> {} - } + ResultEffect(resultBus) { + viewModel.store.onIntent(AlbumDetailIntent.RefreshPhotos) + resultBus.sendResult(result = AlbumDetailResult, allowDuplicate = false) } AlbumDetailRoute( diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt index e2d0ee81..6e1bff54 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt @@ -44,9 +44,8 @@ sealed interface AllPhotoIntent { data object ClickDeleteDialogConfirmButton : AllPhotoIntent // Result Intent - data class PhotoDeleted(val photoIds: List) : AllPhotoIntent data class ClickFavoriteIcon(val photo: Photo) : AllPhotoIntent - data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AllPhotoIntent + data object RefreshPhotos : AllPhotoIntent } sealed interface AllPhotoSideEffect { @@ -55,4 +54,6 @@ sealed interface AllPhotoSideEffect { data class NavigateToPhotoDetail(val photo: Photo, val index: Int) : AllPhotoSideEffect data class ShowToastMessage(val message: String) : AllPhotoSideEffect data class DownloadImages(val imageUrls: List) : AllPhotoSideEffect + data object NotifyResult : AllPhotoSideEffect + data object RefreshPhotos : AllPhotoSideEffect } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt index 8a1bc4e5..787b80d1 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt @@ -39,6 +39,8 @@ import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo import com.neki.android.core.model.SortOrder +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.feature.archive.api.AllPhotoResult import com.neki.android.core.ui.component.LoadingDialog import com.neki.android.feature.archive.api.ArchiveNavKey import com.neki.android.core.ui.compose.collectWithLifecycle @@ -72,6 +74,7 @@ internal fun AllPhotoRoute( val lazyState = rememberLazyStaggeredGridState() val coroutineScope = rememberCoroutineScope() val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { @@ -111,6 +114,12 @@ internal fun AllPhotoRoute( Timber.e(e) } } + + AllPhotoSideEffect.NotifyResult -> { + resultEventBus.sendResult(result = AllPhotoResult, allowDuplicate = false) + } + + AllPhotoSideEffect.RefreshPhotos -> pagingItems.refresh() } } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt index e08efa01..faa4eed8 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt @@ -111,15 +111,15 @@ class AllPhotoViewModel @Inject constructor( AllPhotoIntent.ClickDeleteDialogConfirmButton -> deleteSelectedPhotos(state, reduce, postSideEffect) // Result Intent - is AllPhotoIntent.PhotoDeleted -> { - deletedPhotoIds.update { it + intent.photoIds.toSet() } - } is AllPhotoIntent.ClickFavoriteIcon -> { val photo = intent.photo val newFavorite = !photo.isFavorite updatedFavorites.update { it + (photo.id to newFavorite) } viewModelScope.launch { photoRepository.updateFavorite(photo.id, newFavorite) + .onSuccess { + postSideEffect(AllPhotoSideEffect.NotifyResult) + } .onFailure { e -> Timber.e(e) updatedFavorites.update { it + (photo.id to photo.isFavorite) } @@ -128,8 +128,10 @@ class AllPhotoViewModel @Inject constructor( } } - is AllPhotoIntent.FavoriteChanged -> { - updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + AllPhotoIntent.RefreshPhotos -> { + deletedPhotoIds.value = emptySet() + updatedFavorites.value = emptyMap() + postSideEffect(AllPhotoSideEffect.RefreshPhotos) } } } @@ -241,6 +243,7 @@ class AllPhotoViewModel @Inject constructor( ) } postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 삭제했어요")) + postSideEffect(AllPhotoSideEffect.NotifyResult) } .onFailure { e -> Timber.e(e) 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 a2f78c26..efec08da 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,16 +1,28 @@ package com.neki.android.feature.archive.impl.photo_detail import com.neki.android.core.model.Photo -import com.neki.android.feature.archive.api.ArchiveResult +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +enum class MemoMode { + Closed, + Preview, + Expanded, + Editing, +} data class PhotoDetailState( val isLoading: Boolean = false, val photos: List = emptyList(), val currentPage: Int = 0, val isShowDeleteDialog: Boolean = false, + val memo: String = "", + val memoModes: ImmutableMap = persistentMapOf(), ) { - val currentIndex get() = if (photos.isEmpty()) 0 else currentPage % photos.size + 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 { @@ -27,6 +39,13 @@ sealed interface PhotoDetailIntent { data object ClickFavoriteIcon : PhotoDetailIntent data class FavoriteCommitted(val photoId: Long, val newFavorite: Boolean) : PhotoDetailIntent data class RevertFavorite(val photoId: Long, val originalFavorite: Boolean) : PhotoDetailIntent + data object ClickMemoIcon : PhotoDetailIntent + data object ClickMemoMore : PhotoDetailIntent + data object ClickMemoText : PhotoDetailIntent + data object ClickMemoFold : PhotoDetailIntent + data class MemoTextChanged(val text: String) : PhotoDetailIntent + data object ClickMemoCancel : PhotoDetailIntent + data class ClickMemoDone(val memo: String) : PhotoDetailIntent data object ClickDeleteIcon : PhotoDetailIntent // Delete Dialog Intent @@ -37,7 +56,7 @@ sealed interface PhotoDetailIntent { sealed interface PhotoDetailSideEffect { data object NavigateBack : PhotoDetailSideEffect - data class NotifyPhotoUpdated(val result: ArchiveResult) : PhotoDetailSideEffect + data object NotifyPhotoUpdated : PhotoDetailSideEffect data class ShowToastMessage(val message: String) : PhotoDetailSideEffect data class DownloadImage(val imageUrl: String) : PhotoDetailSideEffect data class AnimateToPage(val index: Int) : PhotoDetailSideEffect 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 37f74f56..0e790827 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 @@ -2,10 +2,7 @@ package com.neki.android.feature.archive.impl.photo_detail import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.HorizontalPager @@ -14,16 +11,19 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import com.neki.android.core.designsystem.DevicePreview -import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo @@ -31,8 +31,10 @@ import com.neki.android.core.navigation.result.LocalResultEventBus import com.neki.android.core.ui.component.LoadingDialog 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.PhotoDetailActionBar +import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailImageItem import com.neki.android.feature.archive.impl.util.ImageDownloader import kotlinx.coroutines.launch import timber.log.Timber @@ -46,7 +48,9 @@ internal fun PhotoDetailRoute( val context = LocalContext.current val nekiToast = remember { NekiToast(context) } val resultEventBus = LocalResultEventBus.current - val pagerState = rememberPagerState(initialPage = uiState.currentPage) { Int.MAX_VALUE } + val pagerState = rememberPagerState(initialPage = uiState.currentPage) { + uiState.photos.size.coerceAtLeast(1) + } val coroutineScope = rememberCoroutineScope() LaunchedEffect(pagerState) { @@ -58,7 +62,7 @@ internal fun PhotoDetailRoute( viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { PhotoDetailSideEffect.NavigateBack -> navigateBack() - is PhotoDetailSideEffect.NotifyPhotoUpdated -> resultEventBus.sendResult(result = sideEffect.result, allowDuplicate = false) + PhotoDetailSideEffect.NotifyPhotoUpdated -> resultEventBus.sendResult(result = PhotoDetailResult, allowDuplicate = false) is PhotoDetailSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) is PhotoDetailSideEffect.DownloadImage -> { ImageDownloader.downloadImage(context, sideEffect.imageUrl) @@ -91,8 +95,13 @@ internal fun PhotoDetailRoute( internal fun PhotoDetailScreen( uiState: PhotoDetailState = PhotoDetailState(), onIntent: (PhotoDetailIntent) -> Unit = {}, - pagerState: PagerState = rememberPagerState { Int.MAX_VALUE }, + 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) } + Column( modifier = Modifier .fillMaxSize() @@ -106,49 +115,49 @@ internal fun PhotoDetailScreen( HorizontalPager( modifier = Modifier .fillMaxWidth() - .weight(1f), + .weight(1f) + .clipToBounds(), state = pagerState, beyondViewportPageCount = 1, + userScrollEnabled = !isMemoActive, ) { page -> - val index = if (uiState.photos.isEmpty()) 0 else page % uiState.photos.size - Box { - AsyncImage( - modifier = Modifier.fillMaxSize(), - model = uiState.photos.getOrNull(index)?.imageUrl, - contentDescription = null, - contentScale = ContentScale.Fit, - ) - Row(modifier = Modifier.matchParentSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .noRippleClickableSingle { - if (!pagerState.isScrollInProgress) { - onIntent(PhotoDetailIntent.ClickLeftPhoto) - } - }, - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .noRippleClickableSingle { - if (!pagerState.isScrollInProgress) { - onIntent(PhotoDetailIntent.ClickRightPhoto) - } - }, - ) - } - } + 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)) }, + ) } - PhotoDetailActionBar( - isFavorite = uiState.photo.isFavorite, - onClickDownload = { onIntent(PhotoDetailIntent.ClickDownloadIcon) }, - onClickFavorite = { onIntent(PhotoDetailIntent.ClickFavoriteIcon) }, - onClickDelete = { onIntent(PhotoDetailIntent.ClickDeleteIcon) }, - ) + if (uiState.currentMemoMode != MemoMode.Editing) { + PhotoDetailActionBar( + modifier = Modifier.onSizeChanged { size -> + actionBarHeightDp = with(density) { size.height.toDp() } + }, + isFavorite = uiState.photo.isFavorite, + onClickDownload = { onIntent(PhotoDetailIntent.ClickDownloadIcon) }, + onClickFavorite = { onIntent(PhotoDetailIntent.ClickFavoriteIcon) }, + onClickMemo = { onIntent(PhotoDetailIntent.ClickMemoIcon) }, + onClickDelete = { onIntent(PhotoDetailIntent.ClickDeleteIcon) }, + ) + } } if (uiState.isShowDeleteDialog) { 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 10f0cb36..1dfedcd6 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 @@ -7,11 +7,11 @@ import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import com.neki.android.feature.archive.api.ArchiveNavKey -import com.neki.android.feature.archive.api.ArchiveResult 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 @@ -37,6 +37,7 @@ class PhotoDetailViewModel @AssistedInject constructor( initialState = PhotoDetailState( photos = key.photos, currentPage = key.initialIndex, + memo = key.photos.getOrNull(key.initialIndex)?.memo.orEmpty(), ), onIntent = ::onIntent, ) @@ -75,7 +76,9 @@ class PhotoDetailViewModel @AssistedInject constructor( ) { when (intent) { // TopBar Intent - PhotoDetailIntent.ClickBackIcon -> postSideEffect(PhotoDetailSideEffect.NavigateBack) + PhotoDetailIntent.ClickBackIcon -> { + postSideEffect(PhotoDetailSideEffect.NavigateBack) + } // Pager Intent PhotoDetailIntent.ClickLeftPhoto -> { @@ -84,10 +87,20 @@ class PhotoDetailViewModel @AssistedInject constructor( } } - PhotoDetailIntent.ClickRightPhoto -> postSideEffect(PhotoDetailSideEffect.AnimateToPage(state.currentPage + 1)) + PhotoDetailIntent.ClickRightPhoto -> { + if (state.currentPage < state.photos.lastIndex) { + postSideEffect(PhotoDetailSideEffect.AnimateToPage(state.currentPage + 1)) + } + } is PhotoDetailIntent.PageChanged -> { - reduce { copy(currentPage = intent.page) } + reduce { + val newIndex = if (photos.isEmpty()) 0 else intent.page % photos.size + copy( + currentPage = intent.page, + memo = photos.getOrNull(newIndex)?.memo.orEmpty(), + ) + } preloadIfNeeded(reduce) } @@ -96,7 +109,7 @@ class PhotoDetailViewModel @AssistedInject constructor( PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce) is PhotoDetailIntent.FavoriteCommitted -> { committedFavorites[intent.photoId] = intent.newFavorite - postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.FavoriteChanged(intent.photoId, intent.newFavorite))) + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated) } is PhotoDetailIntent.RevertFavorite -> { @@ -109,6 +122,43 @@ 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()) + } + PhotoDetailIntent.ClickMemoMore -> reduce { + copy(memoModes = (memoModes + (photo.id to MemoMode.Expanded)).toImmutableMap()) + } + PhotoDetailIntent.ClickMemoText -> reduce { + copy(memoModes = (memoModes + (photo.id to MemoMode.Editing)).toImmutableMap()) + } + PhotoDetailIntent.ClickMemoFold -> reduce { + copy( + memo = photo.memo, + memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(), + ) + } + PhotoDetailIntent.ClickMemoCancel -> reduce { + copy( + memo = photo.memo, + memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(), + ) + } + is PhotoDetailIntent.ClickMemoDone -> { + val photoId = state.photo.id + reduce { + copy( + memo = intent.memo, + memoModes = (memoModes + (photoId to MemoMode.Preview)).toImmutableMap(), + ) + } + saveMemo(state.copy(memo = intent.memo), reduce, postSideEffect) + } + PhotoDetailIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } // Delete Dialog Intent @@ -118,6 +168,42 @@ class PhotoDetailViewModel @AssistedInject constructor( } } + private fun saveMemo( + state: PhotoDetailState, + reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, + postSideEffect: (PhotoDetailSideEffect) -> Unit, + ) { + val photoId = state.photo.id + val newMemo = state.memo + val oldMemo = state.photo.memo + if (newMemo == oldMemo) return + reduce { + copy( + photos = photos.map { p -> + if (p.id == photoId) p.copy(memo = newMemo) else p + }, + ) + } + viewModelScope.launch { + photoRepository.updateMemo(photoId, newMemo) + .onSuccess { + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated) + } + .onFailure { e -> + Timber.e(e, "updateMemo failed") + reduce { + copy( + memo = oldMemo, + photos = photos.map { p -> + if (p.id == photoId) p.copy(memo = oldMemo) else p + }, + ) + } + postSideEffect(PhotoDetailSideEffect.ShowToastMessage("메모 저장에 실패했어요")) + } + } + } + private fun handleFavoriteToggle( state: PhotoDetailState, reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, @@ -145,7 +231,7 @@ class PhotoDetailViewModel @AssistedInject constructor( photoRepository.deletePhoto(state.photo.id) .onSuccess { reduce { copy(isLoading = false) } - postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.PhotoDeleted(state.photo.id))) + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated) postSideEffect(PhotoDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) postSideEffect(PhotoDetailSideEffect.NavigateBack) } @@ -195,7 +281,6 @@ class PhotoDetailViewModel @AssistedInject constructor( override fun onCleared() { super.onCleared() - val state = store.uiState.value val currentPhoto = state.photo val committedFavorite = committedFavorites[currentPhoto.id] ?: return diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/MemoTextField.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/MemoTextField.kt new file mode 100644 index 00000000..96c0c042 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/MemoTextField.kt @@ -0,0 +1,367 @@ +package com.neki.android.feature.archive.impl.photo_detail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.button.NekiTextButton +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.photo_detail.MemoMode + +@Composable +internal fun MemoTextField( + memo: String, + memoMode: MemoMode, + onClickMemoMore: () -> Unit, + onClickMemoFold: () -> Unit, + onClickMemoText: () -> Unit, + onClickMemoCancel: () -> Unit, + onClickMemoDone: (String) -> Unit, + onMemoTextChanged: (String) -> Unit, +) { + val memoState = remember { TextFieldState(initialText = memo) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var previousMemoMode by remember { mutableStateOf(memoMode) } + + LaunchedEffect(memo, memoMode) { + if (memoMode != MemoMode.Editing) { + memoState.edit { replace(0, length, memo) } + } + } + + LaunchedEffect(memoMode) { + if (memoMode == MemoMode.Editing) { + memoState.edit { replace(0, length, memo) } + focusRequester.requestFocus() + } else if (previousMemoMode == MemoMode.Editing) { + keyboardController?.hide() + } + previousMemoMode = memoMode + } + + LaunchedEffect(memoState, memoMode) { + if (memoMode == MemoMode.Editing) { + snapshotFlow { memoState.text.toString() } + .collect { text -> onMemoTextChanged(text) } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + when (memoMode) { + MemoMode.Editing -> NekiTheme.colorScheme.gray25 + else -> NekiTheme.colorScheme.white + }, + ), + ) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = NekiTheme.colorScheme.gray75, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .then( + when (memoMode) { + MemoMode.Preview, MemoMode.Expanded -> Modifier.noRippleClickableSingle { onClickMemoText() } + else -> Modifier + }, + ) + .padding( + start = 16.dp, + end = 16.dp, + top = 20.dp, + bottom = if (memoMode == MemoMode.Editing) 8.dp else 20.dp, + ), + ) { + when (memoMode) { + MemoMode.Closed -> Unit + MemoMode.Preview -> MemoPreviewContent( + memo = memo, + onClickMemoMore = onClickMemoMore, + ) + + MemoMode.Expanded -> MemoExpandedContent( + memoState = memoState, + focusRequester = focusRequester, + onClickMemoText = onClickMemoText, + onClickMemoFold = onClickMemoFold, + ) + + MemoMode.Editing -> MemoEditingContent( + memoState = memoState, + focusRequester = focusRequester, + onClickMemoCancel = onClickMemoCancel, + onClickMemoDone = onClickMemoDone, + ) + } + } + } +} + +@Composable +private fun MemoPreviewContent( + memo: String, + onClickMemoMore: () -> Unit, +) { + var hasOverflow by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = memo.ifEmpty { "자유롭게 메모를 입력해주세요. (최대 100자)" }, + modifier = Modifier.weight(1f), + style = NekiTheme.typography.body16Regular, + color = if (memo.isEmpty()) NekiTheme.colorScheme.gray300 + else NekiTheme.colorScheme.gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { result -> hasOverflow = result.hasVisualOverflow }, + ) + if (hasOverflow) { + Text( + text = "더보기", + modifier = Modifier.noRippleClickableSingle { onClickMemoMore() }, + style = NekiTheme.typography.body16Medium, + color = NekiTheme.colorScheme.gray300, + ) + } + } +} + +@Composable +private fun ColumnScope.MemoExpandedContent( + memoState: TextFieldState, + focusRequester: FocusRequester, + onClickMemoText: () -> Unit, + onClickMemoFold: () -> Unit, +) { + BasicTextField( + state = memoState, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) onClickMemoText() + }, + textStyle = NekiTheme.typography.body16Medium, + readOnly = true, + inputTransformation = InputTransformation.maxLength(100), + cursorBrush = SolidColor(Color.Transparent), + decorator = { innerTextField -> innerTextField() }, + ) + Text( + text = "메모 접기", + modifier = Modifier + .align(Alignment.End) + .noRippleClickableSingle { onClickMemoFold() }, + style = NekiTheme.typography.body16Medium, + color = NekiTheme.colorScheme.gray200, + ) +} + +@Composable +private fun MemoEditingContent( + memoState: TextFieldState, + focusRequester: FocusRequester, + onClickMemoCancel: () -> Unit, + onClickMemoDone: (String) -> Unit, +) { + val charCount = memoState.text.length + + BasicTextField( + state = memoState, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = NekiTheme.typography.body16Medium.copy(color = NekiTheme.colorScheme.gray700), + inputTransformation = InputTransformation.maxLength(100), + cursorBrush = SolidColor(NekiTheme.colorScheme.gray800), + decorator = { innerTextField -> innerTextField() }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$charCount/100", + style = NekiTheme.typography.caption12Regular, + color = NekiTheme.colorScheme.gray300, + ) + NekiTextButton( + onClick = { memoState.edit { replace(0, length, "") } }, + contentPadding = PaddingValues(10.dp), + ) { + Text( + text = "전체 지우기", + style = NekiTheme.typography.caption12Medium, + color = NekiTheme.colorScheme.gray200, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NekiTextButton( + onClick = onClickMemoCancel, + contentPadding = PaddingValues(10.dp), + ) { + Text( + text = "취소", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray800, + ) + } + NekiTextButton( + onClick = { onClickMemoDone(memoState.text.toString()) }, + contentPadding = PaddingValues(10.dp), + ) { + Text( + text = "완료", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.primary500, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun MemoTextFieldPreviewPreview() { + NekiTheme { + Box { + MemoTextField( + memo = "짧은 메모", + memoMode = MemoMode.Preview, + onClickMemoMore = {}, + onClickMemoFold = {}, + onClickMemoText = {}, + onClickMemoCancel = {}, + onClickMemoDone = {}, + onMemoTextChanged = {}, + ) + } + } +} + +@ComponentPreview +@Composable +private fun MemoTextFieldPreviewOverflowPreview() { + NekiTheme { + Box { + MemoTextField( + memo = "재밌었던 날인데 메모가 길어서 한 줄에 다 안 들어갈 수도 있어요 이렇게 길면 더보기가 나와야 해요", + memoMode = MemoMode.Preview, + onClickMemoMore = {}, + onClickMemoFold = {}, + onClickMemoText = {}, + onClickMemoCancel = {}, + onClickMemoDone = {}, + onMemoTextChanged = {}, + ) + } + } +} + +@ComponentPreview +@Composable +private fun MemoTextFieldExpandedPreview() { + NekiTheme { + Box { + MemoTextField( + memo = "재밌었던 날인데 메모가 길어서 한 줄에 다 안 들어갈 수도 있어요", + memoMode = MemoMode.Expanded, + onClickMemoMore = {}, + onClickMemoFold = {}, + onClickMemoText = {}, + onClickMemoCancel = {}, + onClickMemoDone = {}, + onMemoTextChanged = {}, + ) + } + } +} + +@ComponentPreview +@Composable +private fun MemoTextFieldEditingPreview() { + NekiTheme { + Box { + MemoTextField( + memo = "재밌었던 날", + memoMode = MemoMode.Editing, + onClickMemoMore = {}, + onClickMemoFold = {}, + onClickMemoText = {}, + onClickMemoCancel = {}, + onClickMemoDone = {}, + onMemoTextChanged = {}, + ) + } + } +} + +@ComponentPreview +@Composable +private fun MemoTextFieldEmptyPreview() { + NekiTheme { + Box { + MemoTextField( + memo = "", + memoMode = MemoMode.Preview, + onClickMemoMore = {}, + onClickMemoFold = {}, + onClickMemoText = {}, + onClickMemoCancel = {}, + onClickMemoDone = {}, + onMemoTextChanged = {}, + ) + } + } +} 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 6ef4b450..9b35a59b 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 @@ -1,11 +1,11 @@ package com.neki.android.feature.archive.impl.photo_detail.component import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource @@ -22,14 +22,17 @@ internal fun PhotoDetailActionBar( modifier: Modifier = Modifier, onClickDownload: () -> Unit = {}, onClickFavorite: () -> Unit = {}, + onClickMemo: () -> Unit = {}, onClickDelete: () -> Unit = {}, ) { NekiBothSidesActionBar( - modifier = modifier.fillMaxWidth(), + modifier = modifier, startContent = { - Row { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { NekiIconButton( - modifier = Modifier.padding(8.dp), onClick = onClickDownload, ) { Icon( @@ -40,7 +43,6 @@ internal fun PhotoDetailActionBar( ) } NekiIconButton( - modifier = Modifier.padding(8.dp), onClick = onClickFavorite, ) { Icon( @@ -54,6 +56,16 @@ internal fun PhotoDetailActionBar( else NekiTheme.colorScheme.gray700, ) } + NekiIconButton( + onClick = onClickMemo, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_memo), + contentDescription = null, + tint = NekiTheme.colorScheme.gray700, + ) + } } }, endContent = { @@ -74,7 +86,7 @@ internal fun PhotoDetailActionBar( @ComponentPreview @Composable -private fun PhotoDetailActionBarNotFavoritePreview() { +private fun PhotoDetailActionBarClosedPreview() { NekiTheme { PhotoDetailActionBar( isFavorite = false, @@ -84,7 +96,7 @@ private fun PhotoDetailActionBarNotFavoritePreview() { @ComponentPreview @Composable -private fun PhotoDetailActionBarFavoritePreview() { +private fun PhotoDetailActionBarMemoActivePreview() { NekiTheme { PhotoDetailActionBar( isFavorite = 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 new file mode 100644 index 00000000..9c0725c2 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailImageItem.kt @@ -0,0 +1,130 @@ +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 + +@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) } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + AsyncImage( + modifier = Modifier + .fillMaxSize() + .padding(bottom = bottomPadding) + .onSizeChanged { contentWidth = it.width } + .zoomable( + zoomState = zoomState, + scrollGesturePropagation = ScrollGesturePropagation.ContentEdge, + onTap = { position: Offset -> + if ( + isScrollInProgress && contentWidth > 0 && + zoomState.scale <= 1f && + isTapEnabled + ) { + if (position.x < contentWidth / 2) { + onClickLeft() + } else { + onClickRight() + } + } + }, + ), + model = imageUrl, + contentDescription = null, + 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, + ) + } + } +} diff --git a/feature/pose/impl/build.gradle.kts b/feature/pose/impl/build.gradle.kts index 9e2beb39..9debeccc 100644 --- a/feature/pose/impl/build.gradle.kts +++ b/feature/pose/impl/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.paging.compose) + implementation(libs.zoomable) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt index ccbe0453..fe16fa7f 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt @@ -1,17 +1,20 @@ package com.neki.android.feature.pose.impl.detail +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth 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.draw.clipToBounds import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable import com.neki.android.core.designsystem.DevicePreview import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -66,15 +69,23 @@ internal fun PoseDetailScreen( title = "포즈 상세", onBack = { onIntent(PoseDetailIntent.ClickBackIcon) }, ) - AsyncImage( - model = uiState.pose.poseImageUrl, - contentDescription = null, + Box( modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - ) + .weight(1f) + .clipToBounds(), + ) { + val zoomState = rememberZoomState() + AsyncImage( + model = uiState.pose.poseImageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .zoomable(zoomState), + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + onSuccess = { state -> zoomState.setContentSize(state.painter.intrinsicSize) }, + ) + } PoseActionBar( isBookmarked = uiState.pose.isBookmarked, onClickBookmark = { onIntent(PoseDetailIntent.ClickBookmarkIcon) }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3311076b..987aa7a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ detekt = "1.23.8" barcodeScanning = "17.3.0" kakao = "2.23.1" coil = "3.3.0" +zoomable = "2.11.1" haze = "1.7.1" lottie = "6.7.1" exifinterface = "1.4.2" @@ -108,6 +109,8 @@ kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } +zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } + haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" }