From 197d1e88a78269d66f693c995311aea4d7b344a9 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:40:02 +0900 Subject: [PATCH 01/39] =?UTF-8?q?[chore]=20#64:=20`core:data-api`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=84=20Android=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin 라이브러리(`neki.kotlin.library`)에서 Android 라이브러리(`neki.android.library`)로 모듈 타입을 변경하고, 이에 따라 `namespace`를 설정합니다. --- core/data-api/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index 2cbcec861..82d15fe3b 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -1,5 +1,9 @@ plugins { - alias(libs.plugins.neki.kotlin.library) + alias(libs.plugins.neki.android.library) +} + +android { + namespace = "com.neki.android.core.dataapi" } dependencies { From 367fd97af5392940831cbd90291718e38c0a85c7 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:41:13 +0900 Subject: [PATCH 02/39] =?UTF-8?q?[feat]=20#64:=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=8B=B0=EC=BC=93=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20URI?= =?UTF-8?q?/URL=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [refactor] 업로드 티켓 발급 API 분리 - 기존 `getUploadTicket`을 `getMultipleUploadTicket`으로 변경합니다. - 단일 파일 업로드를 위한 `getSingleUploadTicket` 메서드를 추가합니다. [feat] 이미지 업로드 Repository 기능 추가 - `Uri` 또는 `String` URL을 통해 이미지를 직접 업로드할 수 있는 `uploadImageFromUri`와 `uploadImageFromUrl` 메서드를 `MediaUploadRepository`에 추가합니다. --- .../repository/MediaUploadRepository.kt | 23 +++++++++++++++++-- .../impl/MediaUploadRepositoryImpl.kt | 20 +++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt index f2352224e..d185ed876 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt @@ -1,11 +1,18 @@ package com.neki.android.core.dataapi.repository +import android.net.Uri import com.neki.android.core.model.ContentType import com.neki.android.core.model.MediaUploadTicket interface MediaUploadRepository { - suspend fun getUploadTicket( - uploadCount: Int = 1, + suspend fun getSingleUploadTicket( + fileName: String, + contentType: String, + mediaType: String, + ): Result + + suspend fun getMultipleUploadTicket( + uploadCount: Int, fileName: String, contentType: String, mediaType: String, @@ -16,4 +23,16 @@ interface MediaUploadRepository { imageBytes: ByteArray, contentType: ContentType, ): Result + + suspend fun uploadImageFromUri( + uploadUrl: String, + uri: Uri, + contentType: ContentType, + ): Result + + suspend fun uploadImageFromUrl( + uploadUrl: String, + imageUrl: String, + contentType: ContentType, + ): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt index 870c5f236..9793ee4b5 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt @@ -10,7 +10,25 @@ import javax.inject.Inject class MediaUploadRepositoryImpl @Inject constructor( private val uploadService: UploadService, ) : MediaUploadRepository { - override suspend fun getUploadTicket( + override suspend fun getSingleUploadTicket( + fileName: String, + contentType: String, + mediaType: String, + ) = runSuspendCatching { + uploadService.getUploadTicket( + requestBody = MediaUploadTicketRequest( + items = listOf( + MediaUploadTicketRequest.Item( + filename = fileName, + contentType = contentType, + mediaType = mediaType, + ), + ), + ), + ).data.toModels().first() + } + + override suspend fun getMultipleUploadTicket( uploadCount: Int, fileName: String, contentType: String, From 1bc5510eac1a82d648bd5f98bb5918b2a988fc65 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:41:28 +0900 Subject: [PATCH 03/39] =?UTF-8?q?[feat]=20#64:=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=EC=9D=98=20=EC=82=AC=EC=A7=84=EC=9D=84=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=98=EB=8A=94=20UseCase=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UploadMultiplePhotoUseCase`를 구현하여 여러 장의 사진을 한 번에 업로드하는 기능을 추가합니다. 이 UseCase는 다음의 순서로 동작합니다. 1. 업로드할 이미지 개수만큼 업로드 티켓을 한 번에 발급받습니다. 2. 각 이미지를 발급받은 Presigned URL을 통해 업로드합니다. 3. 업로드가 완료된 모든 사진을 서버에 등록합니다. --- .../usecase/UploadMultiplePhotoUseCase.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt new file mode 100644 index 000000000..ca6093144 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt @@ -0,0 +1,70 @@ +package com.neki.android.core.domain.usecase + +import android.net.Uri +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.Media +import com.neki.android.core.model.MediaType +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadMultiplePhotoUseCase @Inject constructor( + private val mediaUploadRepository: MediaUploadRepository, + private val photoRepository: PhotoRepository, +) { + suspend operator fun invoke( + imageUris: List, + contentType: ContentType = ContentType.JPEG, + folderId: Long? = null, + ): Result> = runSuspendCatching { + require(imageUris.isNotEmpty()) { "imageUris must not be empty" } + + val fileName = generateFileName(contentType) + + // 1. 업로드 티켓 발급 (이미지 개수만큼) + val tickets = mediaUploadRepository.getMultipleUploadTicket( + uploadCount = imageUris.size, + fileName = fileName, + contentType = contentType.label, + mediaType = MediaType.PHOTO_BOOTH.name, + ).getOrThrow() + + // 2. 각 이미지를 Presigned URL로 업로드 + imageUris.forEachIndexed { index, uri -> + val ticket = tickets[index] + mediaUploadRepository.uploadImageFromUri( + uploadUrl = ticket.uploadUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + } + + // 3. 사진 등록 (모든 mediaId를 한번에) + val mediaIds = tickets.map { it.mediaId } + photoRepository.registerPhoto( + mediaIds = mediaIds, + folderId = folderId, + ).getOrThrow() + + return@runSuspendCatching tickets.map { ticket -> + Media( + mediaId = ticket.mediaId, + folderId = folderId, + fileName = fileName, + contentType = contentType, + ) + } + } + + private fun generateFileName(contentType: ContentType): String { + val extension = when (contentType) { + ContentType.JPEG -> "jpeg" + ContentType.PNG -> "png" + } + return "${UUID.randomUUID()}.$extension" + } +} From cb51a9a7531b9ce5f6efc5d11a547fde37a96a16 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:42:02 +0900 Subject: [PATCH 04/39] =?UTF-8?q?[feat]=20#64:=20MediaUploadRepository=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MediaUploadRepository`에 Uri 또는 Url로부터 이미지를 업로드하는 기능을 추가합니다. - `uploadImageFromUri`: `Uri`를 `ByteArray`로 변환하여 업로드합니다. - `uploadImageFromUrl`: 이미지 `Url`을 `ByteArray`로 변환하여 업로드합니다. - `ContentType`을 `Bitmap.CompressFormat`으로 변환하는 확장 함수를 추가합니다. --- .../impl/MediaUploadRepositoryImpl.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt index 9793ee4b5..2c5551501 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt @@ -1,13 +1,20 @@ package com.neki.android.core.data.repository.impl +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import com.neki.android.core.common.util.toByteArray +import com.neki.android.core.common.util.urlToByteArray import com.neki.android.core.data.remote.api.UploadService import com.neki.android.core.data.remote.model.request.MediaUploadTicketRequest import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.model.ContentType +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class MediaUploadRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, private val uploadService: UploadService, ) : MediaUploadRepository { override suspend fun getSingleUploadTicket( @@ -58,4 +65,42 @@ class MediaUploadRepositoryImpl @Inject constructor( contentType = contentType.label, ) } + + override suspend fun uploadImageFromUri( + uploadUrl: String, + uri: Uri, + contentType: ContentType, + ) = runSuspendCatching { + val imageBytes = uri.toByteArray( + context = context, + format = contentType.toCompressFormat(), + ) ?: error("Failed to convert uri to byte array") + + uploadService.uploadImage( + presignedUrl = uploadUrl, + imageBytes = imageBytes, + contentType = contentType.label, + ) + } + + override suspend fun uploadImageFromUrl( + uploadUrl: String, + imageUrl: String, + contentType: ContentType, + ) = runSuspendCatching { + val imageBytes = imageUrl.urlToByteArray( + format = contentType.toCompressFormat(), + ) + + uploadService.uploadImage( + presignedUrl = uploadUrl, + imageBytes = imageBytes, + contentType = contentType.label, + ) + } + + private fun ContentType.toCompressFormat(): Bitmap.CompressFormat = when (this) { + ContentType.JPEG -> Bitmap.CompressFormat.JPEG + ContentType.PNG -> Bitmap.CompressFormat.PNG + } } From 94b2c4d103a1efa4d943867943aff2606d018ccb Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:42:11 +0900 Subject: [PATCH 05/39] =?UTF-8?q?[refactor]=20#64:=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20ByteArray=EC=97=90=EC=84=9C=20URL=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UploadSinglePhotoUseCase`에서 이미지를 업로드할 때 `ByteArray`를 직접 전달하는 대신 이미지 `URL`을 사용하도록 수정합니다. 이에 따라 `getUploadTicket`은 `getSingleUploadTicket`으로, `uploadImage`는 `uploadImageFromUrl`로 변경되었습니다. --- .../core/domain/usecase/UploadSinglePhotoUseCase.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt index 1c9664caf..3f9531370 100644 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt @@ -16,23 +16,23 @@ class UploadSinglePhotoUseCase @Inject constructor( private val photoRepository: PhotoRepository, ) { suspend operator fun invoke( - imageBytes: ByteArray, + imageUrl: String, contentType: ContentType = ContentType.JPEG, folderId: Long? = null, ): Result = runSuspendCatching { val fileName = generateFileName(contentType) // 1. 업로드 티켓 발급 (mediaId, presignedUrl) - val (mediaId, presignedUrl) = mediaUploadRepository.getUploadTicket( + val (mediaId, presignedUrl) = mediaUploadRepository.getSingleUploadTicket( fileName = fileName, contentType = contentType.label, mediaType = MediaType.PHOTO_BOOTH.name, - ).getOrThrow().first() + ).getOrThrow() // 2. Presigned URL로 이미지 업로드 - mediaUploadRepository.uploadImage( + mediaUploadRepository.uploadImageFromUrl( uploadUrl = presignedUrl, - imageBytes = imageBytes, + imageUrl = imageUrl, contentType = contentType, ).getOrThrow() From ba9933a99d90862beca01f58b31753608de39b1b Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:42:40 +0900 Subject: [PATCH 06/39] =?UTF-8?q?[feat]=20#64:=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=AC=EB=9F=AC=20=EC=9E=A5=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UploadMultiplePhotoUseCase`를 추가하고, 여러 장의 이미지 Uri를 서버에 업로드하는 기능을 구현합니다. 기존 `UploadType`을 `SINGLE`과 `MULTIPLE`로 변경하여, 단일/다중 이미지 업로드 분기 처리를 명확하게 합니다. --- .../com/neki/android/core/model/UploadType.kt | 4 ++-- .../archive/impl/main/ArchiveMainContract.kt | 2 +- .../archive/impl/main/ArchiveMainViewModel.kt | 24 ++++++++++++++----- .../impl/album/UploadAlbumContract.kt | 2 +- .../impl/album/UploadAlbumViewModel.kt | 24 ++++++++++++++----- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/core/model/src/main/java/com/neki/android/core/model/UploadType.kt b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt index ea2d1bc3d..94c165958 100644 --- a/core/model/src/main/java/com/neki/android/core/model/UploadType.kt +++ b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt @@ -1,6 +1,6 @@ package com.neki.android.core.model enum class UploadType { - QR_SCAN, - GALLERY, + SINGLE, + MULTIPLE, } 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 9a332302e..08998d1b6 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 @@ -20,7 +20,7 @@ data class ArchiveMainState( val isShowAddAlbumBottomSheet: Boolean = false, ) { val uploadType: UploadType - get() = if (scannedImageUrl == null) UploadType.GALLERY else UploadType.QR_SCAN + get() = if (scannedImageUrl != null || selectedUris.isNotEmpty()) UploadType.SINGLE else UploadType.MULTIPLE } sealed interface 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 6b1c13d4e..98b3efce5 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 @@ -3,8 +3,8 @@ package com.neki.android.feature.archive.impl.main import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.neki.android.core.common.util.urlToByteArray import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase import com.neki.android.core.model.UploadType import com.neki.android.core.ui.MviIntentStore @@ -24,6 +24,7 @@ private const val DEFAULT_PHOTOS_SIZE = 20 @HiltViewModel class ArchiveMainViewModel @Inject constructor( private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, private val photoRepository: PhotoRepository, ) : ViewModel() { @@ -161,7 +162,7 @@ class ArchiveMainViewModel @Inject constructor( reduce { copy(isLoading = false) } postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요")) } - if (state.uploadType == UploadType.QR_SCAN) { + if (state.uploadType == UploadType.SINGLE) { uploadSingleImage( imageUrl = state.scannedImageUrl ?: return, reduce = reduce, @@ -186,10 +187,9 @@ class ArchiveMainViewModel @Inject constructor( ) { viewModelScope.launch { reduce { copy(isLoading = true) } - val imageBytes = imageUrl.urlToByteArray() uploadSinglePhotoUseCase( - imageBytes = imageBytes, + imageUrl = imageUrl, ).onSuccess { fetchPhotos(reduce, 1) // 가장 최신 데이터 가져오기 onSuccess() @@ -207,8 +207,20 @@ class ArchiveMainViewModel @Inject constructor( postSideEffect: (ArchiveMainSideEffect) -> Unit, onSuccess: () -> Unit, ) { - // TODO: 이미지 여러개 업로드 - postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadMultiplePhotoUseCase( + imageUris = imageUris, + ).onSuccess { + fetchPhotos(reduce) + onSuccess() + }.onFailure { error -> + Timber.e(error) + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) + reduce { copy(isLoading = false) } + } + } } private fun handleAddAlbum( diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt index 92f228de7..308c88339 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt @@ -18,7 +18,7 @@ data class UploadAlbumState( val count: Int get() = if (imageUrl == null) selectedUris.size else 1 val uploadType: UploadType - get() = if (imageUrl == null) UploadType.GALLERY else UploadType.QR_SCAN + get() = if (imageUrl == null) UploadType.MULTIPLE else UploadType.SINGLE } sealed interface UploadAlbumIntent { diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt index d96733049..b5c1caa9d 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt @@ -4,7 +4,7 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.neki.android.core.common.util.urlToByteArray +import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase import com.neki.android.core.model.Album import com.neki.android.core.model.UploadType @@ -25,6 +25,7 @@ class UploadAlbumViewModel @AssistedInject constructor( @Assisted private val imageUrl: String?, @Assisted private val uriStrings: List, private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, ) : ViewModel() { @AssistedFactory @@ -107,7 +108,7 @@ class UploadAlbumViewModel @AssistedInject constructor( postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) } - if (state.uploadType == UploadType.QR_SCAN) { + if (state.uploadType == UploadType.SINGLE) { uploadSingleImage( imageUrl = state.imageUrl ?: return, albumId = firstAlbumId, @@ -119,6 +120,7 @@ class UploadAlbumViewModel @AssistedInject constructor( uploadMultipleImages( imageUris = state.selectedUris, albumId = firstAlbumId, + reduce = reduce, onSuccessAction = onSuccessAction, onFailureAction = onFailureAction, ) @@ -134,10 +136,9 @@ class UploadAlbumViewModel @AssistedInject constructor( ) { viewModelScope.launch { reduce { copy(isLoading = true) } - val imageBytes = imageUrl.urlToByteArray() uploadSinglePhotoUseCase( - imageBytes = imageBytes, + imageUrl = imageUrl, folderId = albumId, ).onSuccess { data -> Timber.d(data.toString()) @@ -151,10 +152,21 @@ class UploadAlbumViewModel @AssistedInject constructor( private fun uploadMultipleImages( imageUris: List, albumId: Long, + reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, onSuccessAction: () -> Unit, onFailureAction: (Throwable) -> Unit, ) { - // TODO: 이미지 여러개 업로드 - onFailureAction(Throwable("Not implemented")) + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadMultiplePhotoUseCase( + imageUris = imageUris, + folderId = albumId, + ).onSuccess { + onSuccessAction() + }.onFailure { error -> + onFailureAction(error) + } + } } } From e82e376b00675543e554488a722ece81782fba8d Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:47:00 +0900 Subject: [PATCH 07/39] =?UTF-8?q?[feat]=20#69:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 폴더 목록을 조회하고, 새로운 폴더를 생성하는 기능을 위한 API 연동 코드를 추가합니다. - `FolderService`에 폴더 목록 조회(`getFolders`) 및 폴더 생성(`createFolder`) API 함수를 정의합니다. - `FolderRepository` 인터페이스와 그 구현체인 `FolderRepositoryImpl`을 추가하여 데이터 계층을 정의합니다. - 폴더 생성 요청(`CreateFolderRequest`), 폴더 생성 응답(`CreateFolderResponse`), 폴더 목록 응답(`FolderResponse`)에 사용될 데이터 모델을 추가합니다. --- .../dataapi/repository/FolderRepository.kt | 8 ++++++ .../core/data/remote/api/FolderService.kt | 26 ++++++++++++++++++ .../model/request/CreateFolderRequest.kt | 9 +++++++ .../model/response/CreateFolderResponse.kt | 9 +++++++ .../remote/model/response/FolderResponse.kt | 27 +++++++++++++++++++ .../data/repository/di/RepositoryModule.kt | 8 ++++++ .../repository/impl/FolderRepositoryImpl.kt | 22 +++++++++++++++ 7 files changed, 109 insertions(+) create mode 100644 core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt new file mode 100644 index 000000000..c02f4881a --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.AlbumPreview + +interface FolderRepository { + suspend fun getFolders(): Result> + suspend fun createFolder(name: String): Result +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt new file mode 100644 index 000000000..a0cc3ca09 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.CreateFolderResponse +import com.neki.android.core.data.remote.model.response.FolderResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class FolderService @Inject constructor( + private val client: HttpClient, +) { + // 폴더 목록 조회 + suspend fun getFolders(): BasicResponse { + return client.get("/api/folders").body() + } + + // 폴더 생성 + suspend fun createFolder(requestBody: CreateFolderRequest): BasicResponse { + return client.post("/api/folders") { setBody(requestBody) }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt new file mode 100644 index 000000000..337387fe3 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.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 CreateFolderRequest( + @SerialName("name") val name: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt new file mode 100644 index 000000000..9f1a065d9 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateFolderResponse( + @SerialName("folderId") val folderId: Long, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt new file mode 100644 index 000000000..9cdc89e28 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt @@ -0,0 +1,27 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.AlbumPreview +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FolderResponse( + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("folderId") val folderId: Long, + @SerialName("name") val name: String, + @SerialName("latestImageUrl") val latestImageUrl: String?, + @SerialName("totalCount") val totalCount: Int, + ) { + internal fun toModel() = AlbumPreview( + id = folderId, + title = name, + thumbnailUrl = latestImageUrl, + photoCount = totalCount, + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index 018f02a4c..d529478c5 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -4,9 +4,11 @@ import com.neki.android.core.data.auth.AuthEventManagerImpl import com.neki.android.core.data.repository.impl.AuthRepositoryImpl import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl +import com.neki.android.core.data.repository.impl.FolderRepositoryImpl import com.neki.android.core.data.repository.impl.PhotoRepositoryImpl import com.neki.android.core.data.repository.impl.TokenRepositoryImpl import com.neki.android.core.dataapi.auth.AuthEventManager +import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.dataapi.repository.DataStoreRepository import com.neki.android.core.dataapi.repository.MediaUploadRepository @@ -57,4 +59,10 @@ internal interface RepositoryModule { fun bindPhotoRepositoryImpl( photoRepositoryImpl: PhotoRepositoryImpl, ): PhotoRepository + + @Binds + @Singleton + fun bindFolderRepositoryImpl( + folderRepositoryImpl: FolderRepositoryImpl, + ): FolderRepository } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt new file mode 100644 index 000000000..c40860654 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -0,0 +1,22 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.FolderService +import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.model.AlbumPreview +import javax.inject.Inject + +class FolderRepositoryImpl @Inject constructor( + private val folderService: FolderService, +) : FolderRepository { + override suspend fun getFolders(): Result> = runSuspendCatching { + folderService.getFolders().data.toModels() + } + + override suspend fun createFolder(name: String): Result = runSuspendCatching { + folderService.createFolder( + requestBody = CreateFolderRequest(name = name), + ).data.folderId + } +} From 956d3c955e080ec358ca5e2a9e875278e9c0e5bd Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:47:17 +0900 Subject: [PATCH 08/39] =?UTF-8?q?[feat]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보관함 화면 진입 시 즐겨찾기, 전체 사진과 함께 앨범 목록을 조회하도록 `fetchFolders` 함수를 추가하고 `init` 블록에서 호출합니다. --- .../archive/impl/main/ArchiveMainViewModel.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 98b3efce5..a0d680cd4 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 @@ -3,6 +3,7 @@ package com.neki.android.feature.archive.impl.main import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase @@ -26,6 +27,7 @@ class ArchiveMainViewModel @Inject constructor( private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, ) : ViewModel() { val store: MviIntentStore = @@ -125,6 +127,7 @@ class ArchiveMainViewModel @Inject constructor( awaitAll( async { fetchFavoriteSummary(reduce) }, async { fetchPhotos(reduce) }, + async { fetchFolders(reduce) }, ) } finally { reduce { copy(isLoading = false) } @@ -152,6 +155,16 @@ class ArchiveMainViewModel @Inject constructor( } } + private suspend fun fetchFolders(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + private fun uploadWithoutAlbum( state: ArchiveMainState, reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, From 1b9f5b60532fc4baf4f9c4c3f96133971a5cfd36 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:49:00 +0900 Subject: [PATCH 09/39] =?UTF-8?q?[feat]=20#69:=20=EC=83=88=20=EC=95=A8?= =?UTF-8?q?=EB=B2=94=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `folderRepository.createFolder()`를 호출하여 사용자가 입력한 이름으로 새 앨범(폴더)을 생성하는 기능을 구현합니다. 앨범 생성이 성공하면 폴더 목록을 다시 불러와 화면을 갱신합니다. 생성 실패 시에는 에러 로그를 기록합니다. --- .../feature/archive/impl/main/ArchiveMainViewModel.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 a0d680cd4..c756fddd3 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 @@ -242,6 +242,17 @@ class ArchiveMainViewModel @Inject constructor( postSideEffect: (ArchiveMainSideEffect) -> Unit, ) { // TODO: Add album to repository + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { folderId -> + fetchFolders(reduce) + Timber.d("folderId: $folderId") + } + .onFailure { error -> + Timber.e(error) + } + } + reduce { copy(isShowAddAlbumBottomSheet = false) } postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } From e566956a7c509353dd074b6068eb39c2e555a7a6 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:00:54 +0900 Subject: [PATCH 10/39] =?UTF-8?q?[feat]=20#69:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=20=ED=99=94=EB=A9=B4=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 사용하던 더미 데이터를 제거하고, `FolderRepository`와 `PhotoRepository`를 사용하여 실제 앨범 및 즐겨찾는 사진 요약 정보를 가져오도록 `AllAlbumViewModel`을 수정합니다. --- .../core/ui/component/AlbumRowComponent.kt | 26 ++--- .../archive/impl/album/AllAlbumContract.kt | 9 +- .../archive/impl/album/AllAlbumScreen.kt | 91 +++------------ .../archive/impl/album/AllAlbumViewModel.kt | 110 +++++++----------- 4 files changed, 76 insertions(+), 160 deletions(-) diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt index 4b0688939..606c7b9f7 100644 --- a/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt @@ -24,13 +24,11 @@ import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.R import com.neki.android.core.designsystem.modifier.noRippleClickable import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.core.model.Album -import com.neki.android.core.model.Photo -import kotlinx.collections.immutable.persistentListOf +import com.neki.android.core.model.AlbumPreview @Composable fun FavoriteAlbumRowComponent( - album: Album, + album: AlbumPreview, modifier: Modifier = Modifier, onClick: () -> Unit = {}, ) { @@ -43,19 +41,19 @@ fun FavoriteAlbumRowComponent( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { FavoriteAlbumThumbnail( - thumbnailUrl = album.photoList.firstOrNull()?.imageUrl, + thumbnailUrl = album.thumbnailUrl, ) AlbumInfo( title = "즐겨찾는 사진", - photoCount = album.photoList.size, + photoCount = album.photoCount, ) } } @Composable fun AlbumRowComponent( - album: Album, + album: AlbumPreview, modifier: Modifier = Modifier, isSelectable: Boolean = false, isSelected: Boolean = false, @@ -76,7 +74,7 @@ fun AlbumRowComponent( AlbumInfo( modifier = Modifier.weight(1f), title = album.title, - photoCount = album.photoList.size, + photoCount = album.photoCount, ) if (isSelectable) { @@ -168,7 +166,7 @@ private fun AlbumInfo( private fun FavoriteAlbumRowComponentPreview() { NekiTheme { FavoriteAlbumRowComponent( - album = Album( + album = AlbumPreview( id = 0, title = "즐겨찾는 사진", ), @@ -181,7 +179,7 @@ private fun FavoriteAlbumRowComponentPreview() { private fun AlbumRowComponentPreview() { NekiTheme { AlbumRowComponent( - album = Album( + album = AlbumPreview( id = 1, title = "일반앨범제목", ), @@ -195,20 +193,16 @@ private fun AlbumRowComponentSelectablePreview() { NekiTheme { Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { AlbumRowComponent( - album = Album( + album = AlbumPreview( id = 1, title = "선택되지 않은 앨범", - photoList = persistentListOf( - Photo(), - Photo(), - ), ), isSelectable = true, isSelected = false, ) AlbumRowComponent( - album = Album( + album = AlbumPreview( id = 2, title = "선택된 앨범", ), 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 0e15d7125..ff46cc520 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 @@ -1,15 +1,16 @@ package com.neki.android.feature.archive.impl.album -import com.neki.android.core.model.Album +import com.neki.android.core.model.AlbumPreview import com.neki.android.feature.archive.impl.model.SelectMode import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class AllAlbumState( - val favoriteAlbum: Album = Album(), - val albums: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, + val favoriteAlbum: AlbumPreview = AlbumPreview(), + val albums: ImmutableList = persistentListOf(), val selectMode: SelectMode = SelectMode.DEFAULT, - val selectedAlbums: ImmutableList = persistentListOf(), + val selectedAlbums: ImmutableList = persistentListOf(), val isShowOptionPopup: Boolean = false, val selectedDeleteOption: AlbumDeleteOption = AlbumDeleteOption.DELETE_WITH_PHOTOS, val isShowAddAlbumBottomSheet: Boolean = false, 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 50161c447..f17d00b1b 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 @@ -22,8 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.DevicePreview import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.core.model.Album -import com.neki.android.core.model.Photo +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.ui.component.AlbumRowComponent import com.neki.android.core.ui.component.FavoriteAlbumRowComponent import com.neki.android.core.ui.compose.collectWithLifecycle @@ -163,50 +162,16 @@ internal fun AllAlbumScreen( @DevicePreview @Composable private fun AllAlbumScreenPreview() { - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/album_travel1/200/300"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/album_travel2/200/280"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/album_travel3/200/320"), - Photo(id = 104, imageUrl = "https://picsum.photos/seed/album_travel4/200/260"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/album_family1/200/300"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/album_family2/200/290"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/album_friend1/200/300"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/album_friend2/200/310"), - Photo(id = 303, imageUrl = "https://picsum.photos/seed/album_friend3/200/280"), - ) - - val partyPhotos = persistentListOf( - Photo(id = 401, imageUrl = "https://picsum.photos/seed/album_party1/200/300"), - Photo(id = 402, imageUrl = "https://picsum.photos/seed/album_party2/200/320"), - Photo(id = 403, imageUrl = "https://picsum.photos/seed/album_party3/200/280"), - Photo(id = 404, imageUrl = "https://picsum.photos/seed/album_party4/200/290"), - Photo(id = 405, imageUrl = "https://picsum.photos/seed/album_party5/200/310"), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 501, imageUrl = "https://picsum.photos/seed/album_fav1/200/300"), - Photo(id = 502, imageUrl = "https://picsum.photos/seed/album_fav2/200/280"), - Photo(id = 503, imageUrl = "https://picsum.photos/seed/album_fav3/200/320"), - ) - - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2024", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", photoList = friendPhotos), - Album(id = 4, title = "회사 송년회", photoList = partyPhotos), - ) - NekiTheme { AllAlbumScreen( uiState = AllAlbumState( - favoriteAlbum = Album(id = 0, title = "즐겨찾는 사진", photoList = favoritePhotos), - albums = dummyAlbums, + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + AlbumPreview(id = 4, title = "회사 송년회", photoCount = 5), + ), ), ) } @@ -215,40 +180,20 @@ private fun AllAlbumScreenPreview() { @DevicePreview @Composable private fun AllAlbumScreenSelectingPreview() { - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/sel_travel1/200/300"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/sel_travel2/200/280"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/sel_travel3/200/320"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/sel_family1/200/300"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/sel_family2/200/290"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/sel_friend1/200/300"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/sel_friend2/200/310"), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 501, imageUrl = "https://picsum.photos/seed/sel_fav1/200/300"), - Photo(id = 502, imageUrl = "https://picsum.photos/seed/sel_fav2/200/280"), - ) - - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2024", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", photoList = friendPhotos), - ) - NekiTheme { AllAlbumScreen( uiState = AllAlbumState( - favoriteAlbum = Album(id = 0, title = "즐겨찾는 사진", photoList = favoritePhotos), - albums = dummyAlbums, + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), selectMode = SelectMode.SELECTING, - selectedAlbums = persistentListOf(dummyAlbums[0], dummyAlbums[2]), + selectedAlbums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), ), ) } 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 7ee445a6f..26f772e47 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 @@ -1,18 +1,27 @@ package com.neki.android.feature.archive.impl.album import androidx.lifecycle.ViewModel -import com.neki.android.core.model.Album -import com.neki.android.core.model.Photo +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.ui.MviIntentStore -import com.neki.android.feature.archive.impl.model.SelectMode import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.impl.model.SelectMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel -class AllAlbumViewModel @Inject constructor() : ViewModel() { +class AllAlbumViewModel @Inject constructor( + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, +) : ViewModel() { val store: MviIntentStore = mviIntentStore( @@ -70,70 +79,37 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { } private fun fetchInitialData(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/travel1/400/500", date = "2025.01.10"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/travel2/400/600", date = "2025.01.10"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/travel3/400/480", date = "2025.01.09"), - Photo(id = 104, imageUrl = "https://picsum.photos/seed/travel4/400/550", date = "2025.01.09"), - Photo(id = 105, imageUrl = "https://picsum.photos/seed/travel5/400/620", date = "2025.01.08"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/family1/400/520", date = "2025.01.05"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/family2/400/680", date = "2025.01.05"), - Photo(id = 203, imageUrl = "https://picsum.photos/seed/family3/400/450", date = "2025.01.04"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/friend1/400/580", date = "2024.12.25"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/friend2/400/620", date = "2024.12.25"), - Photo(id = 303, imageUrl = "https://picsum.photos/seed/friend3/400/500", date = "2024.12.24"), - Photo(id = 304, imageUrl = "https://picsum.photos/seed/friend4/400/700", date = "2024.12.24"), - Photo(id = 305, imageUrl = "https://picsum.photos/seed/friend5/400/460", date = "2024.12.23"), - ) - - val workPhotos = persistentListOf( - Photo(id = 401, imageUrl = "https://picsum.photos/seed/work1/400/550", date = "2024.12.20"), - Photo(id = 402, imageUrl = "https://picsum.photos/seed/work2/400/480", date = "2024.12.19"), - Photo(id = 403, imageUrl = "https://picsum.photos/seed/work3/400/620", date = "2024.12.18"), - ) - - val petPhotos = persistentListOf( - Photo(id = 501, imageUrl = "https://picsum.photos/seed/pet1/400/600", date = "2024.12.15"), - Photo(id = 502, imageUrl = "https://picsum.photos/seed/pet2/400/520", date = "2024.12.14"), - Photo(id = 503, imageUrl = "https://picsum.photos/seed/pet3/400/680", date = "2024.12.13"), - Photo(id = 504, imageUrl = "https://picsum.photos/seed/pet4/400/450", date = "2024.12.12"), - ) - - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2025", thumbnailUrl = "https://picsum.photos/seed/travel1/400/500", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", thumbnailUrl = "https://picsum.photos/seed/family1/400/520", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", thumbnailUrl = "https://picsum.photos/seed/friend1/400/580", photoList = friendPhotos), - Album(id = 4, title = "회사 워크샵", thumbnailUrl = "https://picsum.photos/seed/work1/400/550", photoList = workPhotos), - Album(id = 5, title = "우리집 반려동물", thumbnailUrl = "https://picsum.photos/seed/pet1/400/600", photoList = petPhotos), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 601, imageUrl = "https://picsum.photos/seed/fav1/400/520", isFavorite = true, date = "2025.01.15"), - Photo(id = 602, imageUrl = "https://picsum.photos/seed/fav2/400/680", isFavorite = true, date = "2025.01.14"), - Photo(id = 603, imageUrl = "https://picsum.photos/seed/fav3/400/450", isFavorite = true, date = "2025.01.13"), - Photo(id = 604, imageUrl = "https://picsum.photos/seed/fav4/400/600", isFavorite = true, date = "2025.01.12"), - Photo(id = 605, imageUrl = "https://picsum.photos/seed/fav5/400/550", isFavorite = true, date = "2025.01.11"), - ) + viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchFolders(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } + } + } - val favoriteAlbum = Album( - id = 0, - title = "즐겨찾는 사진", - thumbnailUrl = favoritePhotos.firstOrNull()?.imageUrl, - photoList = favoritePhotos, - ) + private suspend fun fetchFavoriteSummary(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } - reduce { - copy( - favoriteAlbum = favoriteAlbum, - albums = dummyAlbums, - ) - } + private suspend fun fetchFolders(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } } private fun handleBackClick( @@ -200,7 +176,7 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { reduce { copy( isShowAddAlbumBottomSheet = false, - albums = (albums + Album(id = albums.size.toLong(), title = albumName)).toImmutableList(), + albums = (albums + AlbumPreview(id = albums.size.toLong(), title = albumName)).toImmutableList(), ) } postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) From 41c8a06a0b51eae6633b45045338c4bd47016a5d Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:04:26 +0900 Subject: [PATCH 11/39] =?UTF-8?q?[feat]=20#69:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=20=EC=95=A8=EB=B2=94=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PhotoRepository`와 `FolderRepository`를 사용하여 즐겨찾기 앨범 정보와 전체 앨범 목록을 가져오도록 구현합니다. - 로딩 상태를 관리하기 위해 `isLoading` 상태를 추가하고, 데이터 fetch 시 `isLoading`을 true/false로 업데이트합니다. - `Album` 모델을 `AlbumPreview` 모델로 변경하여 화면에 필요한 최소한의 데이터만 사용하도록 수정합니다. --- .../impl/album/UploadAlbumContract.kt | 6 +- .../impl/album/UploadAlbumScreen.kt | 88 ++++--------------- .../impl/album/UploadAlbumViewModel.kt | 58 +++++++----- 3 files changed, 54 insertions(+), 98 deletions(-) diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt index 308c88339..6afdee0d9 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt @@ -1,7 +1,7 @@ package com.neki.android.feature.photo_upload.impl.album import android.net.Uri -import com.neki.android.core.model.Album +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.UploadType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList @@ -11,8 +11,8 @@ data class UploadAlbumState( val isLoading: Boolean = false, val imageUrl: String? = null, val selectedUris: ImmutableList = persistentListOf(), - val favoriteAlbum: Album = Album(), - val albums: ImmutableList = persistentListOf(), + val favoriteAlbum: AlbumPreview = AlbumPreview(), + val albums: ImmutableList = persistentListOf(), val selectedAlbumIds: PersistentList = persistentListOf(), ) { val count: Int diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt index 5eaa69d97..09cb62eee 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt @@ -18,8 +18,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.Album -import com.neki.android.core.model.Photo +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.ui.component.AlbumRowComponent import com.neki.android.core.ui.component.FavoriteAlbumRowComponent import com.neki.android.core.ui.component.LoadingDialog @@ -101,50 +100,16 @@ internal fun UploadAlbumScreen( @DevicePreview @Composable private fun UploadAlbumScreenPreview() { - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/album_travel1/200/300"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/album_travel2/200/280"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/album_travel3/200/320"), - Photo(id = 104, imageUrl = "https://picsum.photos/seed/album_travel4/200/260"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/album_family1/200/300"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/album_family2/200/290"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/album_friend1/200/300"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/album_friend2/200/310"), - Photo(id = 303, imageUrl = "https://picsum.photos/seed/album_friend3/200/280"), - ) - - val partyPhotos = persistentListOf( - Photo(id = 401, imageUrl = "https://picsum.photos/seed/album_party1/200/300"), - Photo(id = 402, imageUrl = "https://picsum.photos/seed/album_party2/200/320"), - Photo(id = 403, imageUrl = "https://picsum.photos/seed/album_party3/200/280"), - Photo(id = 404, imageUrl = "https://picsum.photos/seed/album_party4/200/290"), - Photo(id = 405, imageUrl = "https://picsum.photos/seed/album_party5/200/310"), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 501, imageUrl = "https://picsum.photos/seed/album_fav1/200/300"), - Photo(id = 502, imageUrl = "https://picsum.photos/seed/album_fav2/200/280"), - Photo(id = 503, imageUrl = "https://picsum.photos/seed/album_fav3/200/320"), - ) - - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2024", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", photoList = friendPhotos), - Album(id = 4, title = "회사 송년회", photoList = partyPhotos), - ) - NekiTheme { UploadAlbumScreen( uiState = UploadAlbumState( - favoriteAlbum = Album(id = 0, title = "즐겨찾는 사진", photoList = favoritePhotos), - albums = dummyAlbums, + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + AlbumPreview(id = 4, title = "회사 송년회", photoCount = 5), + ), ), ) } @@ -153,39 +118,16 @@ private fun UploadAlbumScreenPreview() { @DevicePreview @Composable private fun UploadAlbumScreenSelectingPreview() { - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/sel_travel1/200/300"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/sel_travel2/200/280"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/sel_travel3/200/320"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/sel_family1/200/300"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/sel_family2/200/290"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/sel_friend1/200/300"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/sel_friend2/200/310"), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 501, imageUrl = "https://picsum.photos/seed/sel_fav1/200/300"), - Photo(id = 502, imageUrl = "https://picsum.photos/seed/sel_fav2/200/280"), - ) - - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2024", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", photoList = friendPhotos), - ) - NekiTheme { UploadAlbumScreen( uiState = UploadAlbumState( - favoriteAlbum = Album(id = 0, title = "즐겨찾는 사진", photoList = favoritePhotos), - albums = dummyAlbums, - selectedAlbumIds = persistentListOf(), + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), + selectedAlbumIds = persistentListOf(1L, 3L), ), ) } diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt index b5c1caa9d..4d1c6860a 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt @@ -4,9 +4,10 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase -import com.neki.android.core.model.Album import com.neki.android.core.model.UploadType import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore @@ -14,9 +15,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import timber.log.Timber @@ -26,6 +28,8 @@ class UploadAlbumViewModel @AssistedInject constructor( @Assisted private val uriStrings: List, private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, ) : ViewModel() { @AssistedFactory @@ -68,29 +72,39 @@ class UploadAlbumViewModel @AssistedInject constructor( } private fun fetchInitialData(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { - // TODO: Fetch albums from repository - val dummyAlbums = persistentListOf( - Album( - id = 1, - title = "Travel", - photoList = persistentListOf(), - ), - Album( - id = 2, - title = "Family", - photoList = persistentListOf(), - ), - ) - - reduce { - copy( - albums = dummyAlbums, - imageUrl = imageUrl, - selectedUris = uriStrings.map { it.toUri() }.toImmutableList(), - ) + viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchFolders(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } } } + private suspend fun fetchFavoriteSummary(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private suspend fun fetchFolders(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + private fun handleUploadButtonClick( state: UploadAlbumState, reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, From 9a3ea5e5f989d7fe3370dc6c905fc360abb0cdf9 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:04:43 +0900 Subject: [PATCH 12/39] =?UTF-8?q?[feat]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [refactor] ViewModel에서 더미 데이터 제거 및 API 연동 로직 추가 기존에 사용하던 `dummyPhotos`를 제거하고, `photoRepository.getPhotos()`를 호출하여 실제 사진 목록을 가져오도록 수정합니다. --- .../impl/album_detail/AlbumDetailViewModel.kt | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) 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 b72a43fe3..b0c59299a 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 @@ -28,17 +28,6 @@ class AlbumDetailViewModel @AssistedInject constructor( fun create(id: Long, isFavoriteAlbum: Boolean): AlbumDetailViewModel } - private val dummyPhotos = persistentListOf( - Photo(id = 1001, imageUrl = "https://picsum.photos/seed/detail1/400/520", isFavorite = false, date = "2025.01.15"), - Photo(id = 1002, imageUrl = "https://picsum.photos/seed/detail2/400/680", isFavorite = true, date = "2025.01.14"), - Photo(id = 1003, imageUrl = "https://picsum.photos/seed/detail3/400/450", isFavorite = false, date = "2025.01.13"), - Photo(id = 1004, imageUrl = "https://picsum.photos/seed/detail4/400/600", isFavorite = true, date = "2025.01.12"), - Photo(id = 1005, imageUrl = "https://picsum.photos/seed/detail5/400/550", isFavorite = false, date = "2025.01.11"), - Photo(id = 1006, imageUrl = "https://picsum.photos/seed/detail6/400/720", isFavorite = false, date = "2025.01.10"), - Photo(id = 1007, imageUrl = "https://picsum.photos/seed/detail7/400/480", isFavorite = true, date = "2025.01.09"), - Photo(id = 1008, imageUrl = "https://picsum.photos/seed/detail8/400/650", isFavorite = false, date = "2025.01.08"), - ) - val store: MviIntentStore = mviIntentStore( initialState = AlbumDetailState( @@ -112,10 +101,14 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, ) { // TODO: Fetch album from repository - reduce { - copy( - photoList = dummyPhotos, - ) + viewModelScope.launch { + photoRepository.getPhotos(folderId = id) + .onSuccess { data -> + reduce { copy(photoList = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } } } From 08cc5deb03fd4b1588c4158e88f11cc55b4a193a Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:44:06 +0900 Subject: [PATCH 13/39] =?UTF-8?q?[feat]=20#59:=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paging3 라이브러리를 사용하여 사진 목록을 불러오는 `PagingSource`를 구현합니다. - `PhotoPagingSource`: 폴더 ID를 기반으로 사진 목록을 페이징합니다. - `FavoritePhotoPagingSource`: 즐겨찾기한 사진 목록을 정렬 순서에 따라 페이징합니다. --- core/data-api/build.gradle.kts | 1 + core/data/build.gradle.kts | 1 + .../data/paging/FavoritePhotoPagingSource.kt | 41 +++++++++++++++++++ .../core/data/paging/PhotoPagingSource.kt | 40 ++++++++++++++++++ feature/archive/impl/build.gradle.kts | 1 + gradle/libs.versions.toml | 4 ++ 6 files changed, 88 insertions(+) create mode 100644 core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index 82d15fe3b..c3d918b7f 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { implementation(projects.core.model) implementation(libs.kotlinx.coroutines.core) api(libs.androidx.datastore.preferences) + api(libs.androidx.paging.runtime) } \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 6a132b22c..7df78da5d 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -42,4 +42,5 @@ dependencies { implementation(libs.androidx.datastore.core) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.paging.runtime) } diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt new file mode 100644 index 000000000..ba1648c24 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt @@ -0,0 +1,41 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PhotoService +import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder + +class FavoritePhotoPagingSource( + private val photoService: PhotoService, + private val sortOrder: SortOrder, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = photoService.getFavoritePhotos( + page = page, + size = params.loadSize, + sortOrder = sortOrder.name, + ) + val photos = response.data.toModels() + val hasNext = response.data.hasNext + + LoadResult.Page( + data = photos, + prevKey = if (page == 0) null else page - 1, + nextKey = if (hasNext) page + 1 else null, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt new file mode 100644 index 000000000..049423f06 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt @@ -0,0 +1,40 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PhotoService +import com.neki.android.core.model.Photo + +class PhotoPagingSource( + private val photoService: PhotoService, + private val folderId: Long?, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = photoService.getPhotos( + folderId = folderId, + page = page, + size = params.loadSize, + ) + val photos = response.data.toModels() + val hasNext = response.data.hasNext + + LoadResult.Page( + data = photos, + prevKey = if (page == 0) null else page - 1, + nextKey = if (hasNext) page + 1 else null, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/feature/archive/impl/build.gradle.kts b/feature/archive/impl/build.gradle.kts index 80d598cc2..7540e385c 100644 --- a/feature/archive/impl/build.gradle.kts +++ b/feature/archive/impl/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(projects.feature.photoUpload.api) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.paging.compose) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d022b32b..6929131b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ barcodeScanning = "17.3.0" kakao = "2.23.1" coil = "3.3.0" haze = "1.7.1" +paging = "3.3.2" [libraries] androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } @@ -97,6 +98,9 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp" haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" } +androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } + # Dependencies of the included build-logic kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } From 7a7695e70993f28e18f939c6dfe54faefcb17aa4 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:44:44 +0900 Subject: [PATCH 14/39] =?UTF-8?q?[feat]=20#59:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20Paging=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataapi/repository/PhotoRepository.kt | 8 + .../repository/impl/PhotoRepositoryImpl.kt | 31 ++++ .../impl/album_detail/AlbumDetailContract.kt | 2 +- .../impl/album_detail/AlbumDetailScreen.kt | 154 ++++++------------ .../impl/album_detail/AlbumDetailViewModel.kt | 86 +++++----- 5 files changed, 123 insertions(+), 158 deletions(-) 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 8a38ee35f..f5c22a00d 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 @@ -1,8 +1,10 @@ package com.neki.android.core.dataapi.repository +import androidx.paging.PagingData import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.Photo import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow interface PhotoRepository { suspend fun getPhotos( @@ -28,4 +30,10 @@ interface PhotoRepository { ): Result> suspend fun getFavoriteSummary(): Result + + fun getPhotosFlow(folderId: Long?): Flow> + + fun getFavoritePhotosFlow( + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> } 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 3075ee168..24543e099 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 @@ -1,5 +1,10 @@ package com.neki.android.core.data.repository.impl +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.neki.android.core.data.paging.FavoritePhotoPagingSource +import com.neki.android.core.data.paging.PhotoPagingSource import com.neki.android.core.data.remote.api.PhotoService import com.neki.android.core.data.remote.model.request.DeletePhotoRequest import com.neki.android.core.data.remote.model.request.RegisterPhotoRequest @@ -8,8 +13,12 @@ import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.Photo import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow import javax.inject.Inject +private const val PAGE_SIZE = 20 +private const val PREFETCH_DISTANCE = 5 + class PhotoRepositoryImpl @Inject constructor( private val photoService: PhotoService, ) : PhotoRepository { @@ -64,4 +73,26 @@ class PhotoRepositoryImpl @Inject constructor( override suspend fun getFavoriteSummary(): Result = runSuspendCatching { photoService.getFavoriteSummary().data.toModel() } + + override fun getPhotosFlow(folderId: Long?): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { PhotoPagingSource(photoService, folderId) } + ).flow + } + + override fun getFavoritePhotosFlow(sortOrder: SortOrder): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { FavoritePhotoPagingSource(photoService, sortOrder) } + ).flow + } } 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 b751833b6..00f775677 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 @@ -8,10 +8,10 @@ import kotlinx.collections.immutable.persistentListOf data class AlbumDetailState( val isLoading: Boolean = false, val title: String = "", - val photoList: ImmutableList = persistentListOf(), val isFavoriteAlbum: Boolean = false, val selectMode: SelectMode = SelectMode.DEFAULT, val selectedPhotos: ImmutableList = persistentListOf(), + val deletedPhotoIds: Set = emptySet(), val isShowDeleteDialog: Boolean = false, val isShowDeleteBottomSheet: Boolean = false, val selectedDeleteOption: PhotoDeleteOption = PhotoDeleteOption.REMOVE_FROM_ALBUM, 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 edb0d497b..a44f84685 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 @@ -8,19 +8,24 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api 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.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.neki.android.core.designsystem.DevicePreview +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -37,7 +42,6 @@ import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_H import com.neki.android.feature.archive.impl.model.SelectMode import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar import com.neki.android.feature.archive.impl.util.ImageDownloader -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -47,6 +51,7 @@ internal fun AlbumDetailRoute( navigateToPhotoDetail: (Photo) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } @@ -72,6 +77,7 @@ internal fun AlbumDetailRoute( AlbumDetailScreen( uiState = uiState, + pagingItems = pagingItems, onIntent = viewModel.store::onIntent, ) } @@ -79,11 +85,15 @@ internal fun AlbumDetailRoute( @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AlbumDetailScreen( - uiState: AlbumDetailState = AlbumDetailState(), + uiState: AlbumDetailState, + pagingItems: LazyPagingItems, onIntent: (AlbumDetailIntent) -> Unit = {}, ) { val lazyState = rememberLazyStaggeredGridState() + val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading + val isEmpty = pagingItems.itemCount == 0 && pagingItems.loadState.refresh is LoadState.NotLoading + BackHandler(enabled = true) { onIntent(AlbumDetailIntent.OnBackPressed) } @@ -94,7 +104,7 @@ internal fun AlbumDetailScreen( .background(NekiTheme.colorScheme.white), ) { AlbumDetailTopBar( - hasNoPhoto = uiState.photoList.isEmpty(), + hasNoPhoto = isEmpty, title = if (uiState.isFavoriteAlbum) "즐겨찾는 사진" else uiState.title, selectMode = uiState.selectMode, onClickBack = { onIntent(AlbumDetailIntent.ClickBackIcon) }, @@ -119,17 +129,35 @@ internal fun AlbumDetailScreen( horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), ) { items( - items = uiState.photoList, - key = { photo -> photo.id }, - ) { photo -> - val isSelected = uiState.selectedPhotos.any { it.id == photo.id } - SelectablePhotoItem( - photo = photo, - isSelected = isSelected, - isSelectMode = uiState.selectMode == SelectMode.SELECTING, - onClickItem = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, - onClickSelect = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, - ) + count = pagingItems.itemCount, + key = pagingItems.itemKey { it.id }, + ) { index -> + val photo = pagingItems[index] + if (photo != null) { + val isSelected = uiState.selectedPhotos.any { it.id == photo.id } + SelectablePhotoItem( + photo = photo, + isSelected = isSelected, + isSelectMode = uiState.selectMode == SelectMode.SELECTING, + onClickItem = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, + onClickSelect = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, + ) + } + } + + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NekiTheme.colorScheme.primary500, + ) + } + } } } } @@ -142,17 +170,16 @@ internal fun AlbumDetailScreen( ) } - if (uiState.isLoading) { + if (isRefreshing || uiState.isLoading) { LoadingDialog() } - if (!uiState.isLoading && uiState.photoList.isEmpty()) { + if (isEmpty) { EmptyContent( isFavorite = uiState.isFavoriteAlbum, ) } - // Delete Dialog for Favorite Album if (uiState.isShowDeleteDialog) { DeletePhotoDialog( onDismissRequest = { onIntent(AlbumDetailIntent.DismissDeleteDialog) }, @@ -161,7 +188,6 @@ internal fun AlbumDetailScreen( ) } - // Delete BottomSheet for Regular Album if (uiState.isShowDeleteBottomSheet) { DoubleButtonOptionBottomSheet( title = "사진을 삭제하시겠어요?", @@ -213,91 +239,3 @@ private fun AlbumDetailTopBar( ) } } - -@DevicePreview -@Composable -private fun AlbumDetailScreenFavoriteEmptyPreview() { - NekiTheme { - AlbumDetailScreen( - uiState = AlbumDetailState( - title = "즐겨찾는 사진", - isFavoriteAlbum = true, - ), - ) - } -} - -@DevicePreview -@Composable -private fun AlbumDetailScreenEmptyPreview() { - NekiTheme { - AlbumDetailScreen( - uiState = AlbumDetailState( - title = "빈 앨범", - isFavoriteAlbum = false, - ), - ) - } -} - -@DevicePreview -@Composable -private fun AlbumDetailScreenFavoritePreview() { - val dummyPhotos = persistentListOf( - Photo(id = 1, imageUrl = "https://picsum.photos/200/300", isFavorite = true), - Photo(id = 2, imageUrl = "https://picsum.photos/200/250", isFavorite = true), - Photo(id = 3, imageUrl = "https://picsum.photos/200/350", isFavorite = true), - ) - - NekiTheme { - AlbumDetailScreen( - uiState = AlbumDetailState( - title = "즐겨찾는 사진", - photoList = dummyPhotos, - isFavoriteAlbum = true, - ), - ) - } -} - -@DevicePreview -@Composable -private fun AlbumDetailScreenRegularPreview() { - val dummyPhotos = persistentListOf( - Photo(id = 1, imageUrl = "https://picsum.photos/200/300"), - Photo(id = 2, imageUrl = "https://picsum.photos/200/250"), - Photo(id = 3, imageUrl = "https://picsum.photos/200/350"), - ) - - NekiTheme { - AlbumDetailScreen( - uiState = AlbumDetailState( - title = "네키 화이팅", - photoList = dummyPhotos, - isFavoriteAlbum = false, - ), - ) - } -} - -@DevicePreview -@Composable -private fun AlbumDetailScreenSelectingPreview() { - val dummyPhotos = persistentListOf( - Photo(id = 1, imageUrl = "https://picsum.photos/200/300"), - Photo(id = 2, imageUrl = "https://picsum.photos/200/250"), - Photo(id = 3, imageUrl = "https://picsum.photos/200/350"), - ) - - NekiTheme { - AlbumDetailScreen( - uiState = AlbumDetailState( - title = "네키 화이팅", - photoList = dummyPhotos, - isFavoriteAlbum = false, - selectMode = SelectMode.SELECTING, - selectedPhotos = persistentListOf(dummyPhotos[1]), - ), - ) - } -} 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 b0c59299a..d92838d4d 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 @@ -2,6 +2,9 @@ package com.neki.android.feature.archive.impl.album_detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore @@ -13,6 +16,10 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -28,13 +35,28 @@ class AlbumDetailViewModel @AssistedInject constructor( fun create(id: Long, isFavoriteAlbum: Boolean): AlbumDetailViewModel } + private val deletedPhotoIds = MutableStateFlow>(emptySet()) + + private val originalPagingData: Flow> = + if (isFavoriteAlbum) { + photoRepository.getFavoritePhotosFlow() + } else { + photoRepository.getPhotosFlow(id) + }.cachedIn(viewModelScope) + + val photoPagingData: Flow> = combine( + originalPagingData, + deletedPhotoIds, + ) { pagingData, deletedIds -> + pagingData.filter { photo -> photo.id !in deletedIds } + } + val store: MviIntentStore = mviIntentStore( initialState = AlbumDetailState( isFavoriteAlbum = isFavoriteAlbum, ), onIntent = ::onIntent, - initialFetchData = { store.onIntent(AlbumDetailIntent.EnterAlbumDetailScreen) }, ) private fun onIntent( @@ -44,10 +66,8 @@ class AlbumDetailViewModel @AssistedInject constructor( postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { when (intent) { - // TopBar Intent AlbumDetailIntent.EnterAlbumDetailScreen -> { - if (isFavoriteAlbum) fetchFavoriteData(reduce) - else fetchAlbumData(id, reduce) + // Paging이 자동으로 처리 } AlbumDetailIntent.ClickBackIcon -> handleBackClick(state, reduce, postSideEffect) @@ -60,55 +80,19 @@ class AlbumDetailViewModel @AssistedInject constructor( ) } - // Photo Intent is AlbumDetailIntent.ClickPhotoItem -> handlePhotoClick(intent.photo, state, reduce, postSideEffect) - // ActionBar Intent AlbumDetailIntent.ClickDownloadIcon -> handleDownload(state, postSideEffect) AlbumDetailIntent.ClickDeleteIcon -> handleDeleteIconClick(state, reduce, postSideEffect) - // Delete Dialog Intent (for Favorite Album) AlbumDetailIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } AlbumDetailIntent.ClickDeleteDialogCancelButton -> reduce { copy(isShowDeleteDialog = false) } AlbumDetailIntent.ClickDeleteDialogConfirmButton -> handleFavoriteDelete(state, reduce, postSideEffect) - // Delete BottomSheet Intent (for Regular Album) AlbumDetailIntent.DismissDeleteBottomSheet -> reduce { copy(isShowDeleteBottomSheet = false) } is AlbumDetailIntent.SelectDeleteOption -> reduce { copy(selectedDeleteOption = intent.option) } AlbumDetailIntent.ClickDeleteBottomSheetCancelButton -> reduce { copy(isShowDeleteBottomSheet = false) } - AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton -> handleAlbumDelete(state, reduce, postSideEffect) - } - } - - private fun fetchFavoriteData( - reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, - ) { - viewModelScope.launch { - reduce { copy(isLoading = true) } - photoRepository.getFavoritePhotos() - .onSuccess { data -> - reduce { copy(photoList = data.toImmutableList()) } - } - .onFailure { error -> - Timber.e(error) - } - reduce { copy(isLoading = false) } - } - } - - private fun fetchAlbumData( - id: Long, - reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, - ) { - // TODO: Fetch album from repository - viewModelScope.launch { - photoRepository.getPhotos(folderId = id) - .onSuccess { data -> - reduce { copy(photoList = data.toImmutableList()) } - } - .onFailure { error -> - Timber.e(error) - } + AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton -> handleAlbumPhotoDelete(state, reduce, postSideEffect) } } @@ -188,39 +172,43 @@ class AlbumDetailViewModel @AssistedInject constructor( postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { viewModelScope.launch { - reduce { copy(isLoading = true) } val selectedPhotoIds = state.selectedPhotos.map { it.id } + reduce { copy(isLoading = true) } + photoRepository.deletePhoto(photoIds = selectedPhotoIds) .onSuccess { - Timber.d("삭제 성공2") - fetchFavoriteData(reduce) + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } reduce { copy( selectedPhotos = persistentListOf(), selectMode = SelectMode.DEFAULT, isShowDeleteDialog = false, + isLoading = false, ) } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) } .onFailure { error -> Timber.e(error) + reduce { copy(isLoading = false) } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) } - reduce { copy(isLoading = false) } } } - private fun handleAlbumDelete( + private fun handleAlbumPhotoDelete( state: AlbumDetailState, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + + // UI에서 즉시 필터링 + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { copy( - photoList = photoList.filter { photo -> - selectedPhotos.none { it.id == photo.id } - }.toImmutableList(), selectedPhotos = persistentListOf(), selectMode = SelectMode.DEFAULT, isShowDeleteBottomSheet = false, From 491f052f2e7000a637fca77907bb2339757757eb Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:49:45 +0900 Subject: [PATCH 15/39] =?UTF-8?q?[feat]=20#70:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=ED=99=94=EB=A9=B4=20Paging=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archive/impl/photo/AllPhotoContract.kt | 4 +- .../archive/impl/photo/AllPhotoScreen.kt | 105 ++++++++-------- .../archive/impl/photo/AllPhotoViewModel.kt | 119 ++++++++---------- 3 files changed, 103 insertions(+), 125 deletions(-) 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 e2f4655ab..baff84d1e 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 @@ -6,9 +6,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class AllPhotoState( - val photos: ImmutableList = persistentListOf(), - val sortedDescendingPhotos: ImmutableList = persistentListOf(), - val showingPhotos: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, val selectMode: SelectMode = SelectMode.DEFAULT, val selectedPhotoFilter: PhotoFilter = PhotoFilter.NEWEST, val selectedPhotos: ImmutableList = persistentListOf(), 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 6aca2d298..32be5e19a 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 @@ -8,11 +8,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -20,6 +21,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext @@ -27,11 +29,17 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import com.neki.android.core.designsystem.DevicePreview import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo +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.impl.component.DeletePhotoDialog import com.neki.android.feature.archive.impl.component.SelectablePhotoItem import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_BOTTOM_PADDING @@ -39,10 +47,8 @@ import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_H import com.neki.android.feature.archive.impl.model.SelectMode import com.neki.android.feature.archive.impl.photo.component.AllPhotoFilterBar import com.neki.android.feature.archive.impl.photo.component.AllPhotoTopBar -import com.neki.android.feature.archive.impl.component.DeletePhotoDialog import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar import com.neki.android.feature.archive.impl.util.ImageDownloader -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @Composable @@ -52,6 +58,7 @@ internal fun AllPhotoRoute( navigateToPhotoDetail: (Photo) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() val context = LocalContext.current val lazyState = rememberLazyStaggeredGridState() val coroutineScope = rememberCoroutineScope() @@ -80,6 +87,7 @@ internal fun AllPhotoRoute( AllPhotoScreen( uiState = uiState, + pagingItems = pagingItems, lazyState = lazyState, onIntent = viewModel.store::onIntent, ) @@ -87,7 +95,8 @@ internal fun AllPhotoRoute( @Composable internal fun AllPhotoScreen( - uiState: AllPhotoState = AllPhotoState(), + uiState: AllPhotoState, + pagingItems: LazyPagingItems, lazyState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), onIntent: (AllPhotoIntent) -> Unit = {}, ) { @@ -102,6 +111,8 @@ internal fun AllPhotoScreen( } } + val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading + BackHandler(enabled = true) { onIntent(AllPhotoIntent.OnBackPressed) } @@ -137,17 +148,35 @@ internal fun AllPhotoScreen( horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), ) { items( - items = uiState.showingPhotos, - key = { photo -> photo.id }, - ) { photo -> - val isSelected = uiState.selectedPhotos.any { it.id == photo.id } - SelectablePhotoItem( - photo = photo, - isSelected = isSelected, - isSelectMode = uiState.selectMode == SelectMode.SELECTING, - onClickItem = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, - onClickSelect = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, - ) + count = pagingItems.itemCount, + key = pagingItems.itemKey { it.id }, + ) { index -> + val photo = pagingItems[index] + if (photo != null) { + val isSelected = uiState.selectedPhotos.any { it.id == photo.id } + SelectablePhotoItem( + photo = photo, + isSelected = isSelected, + isSelectMode = uiState.selectMode == SelectMode.SELECTING, + onClickItem = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, + onClickSelect = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, + ) + } + } + + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NekiTheme.colorScheme.primary500, + ) + } + } } } @@ -176,6 +205,10 @@ internal fun AllPhotoScreen( ) } + if (isRefreshing || uiState.isLoading) { + LoadingDialog() + } + if (uiState.isShowDeleteDialog) { DeletePhotoDialog( onDismissRequest = { onIntent(AllPhotoIntent.DismissDeleteDialog) }, @@ -188,47 +221,7 @@ internal fun AllPhotoScreen( @DevicePreview @Composable private fun AllPhotoScreenPreview() { - val dummyPhotos = persistentListOf( - Photo(id = 1, imageUrl = "https://picsum.photos/seed/all1/200/300", isFavorite = true), - Photo(id = 2, imageUrl = "https://picsum.photos/seed/all2/200/250"), - Photo(id = 3, imageUrl = "https://picsum.photos/seed/all3/200/350", isFavorite = true), - Photo(id = 4, imageUrl = "https://picsum.photos/seed/all4/200/280"), - Photo(id = 5, imageUrl = "https://picsum.photos/seed/all5/200/320", isFavorite = true), - Photo(id = 6, imageUrl = "https://picsum.photos/seed/all6/200/260"), - Photo(id = 7, imageUrl = "https://picsum.photos/seed/all7/200/290"), - Photo(id = 8, imageUrl = "https://picsum.photos/seed/all8/200/310", isFavorite = true), - Photo(id = 9, imageUrl = "https://picsum.photos/seed/all9/200/270"), - Photo(id = 10, imageUrl = "https://picsum.photos/seed/all10/200/340"), - ) - NekiTheme { - AllPhotoScreen( - uiState = AllPhotoState( - photos = dummyPhotos, - ), - ) - } -} - -@DevicePreview -@Composable -private fun AllPhotoScreenSelectingPreview() { - val dummyPhotos = persistentListOf( - Photo(id = 1, imageUrl = "https://picsum.photos/seed/sel1/200/300", isFavorite = true), - Photo(id = 2, imageUrl = "https://picsum.photos/seed/sel2/200/250"), - Photo(id = 3, imageUrl = "https://picsum.photos/seed/sel3/200/350", isFavorite = true), - Photo(id = 4, imageUrl = "https://picsum.photos/seed/sel4/200/280"), - Photo(id = 5, imageUrl = "https://picsum.photos/seed/sel5/200/320"), - Photo(id = 6, imageUrl = "https://picsum.photos/seed/sel6/200/290"), - ) - - NekiTheme { - AllPhotoScreen( - uiState = AllPhotoState( - photos = dummyPhotos, - selectMode = SelectMode.SELECTING, - selectedPhotos = persistentListOf(dummyPhotos[0], dummyPhotos[2], dummyPhotos[4]), - ), - ) + // Preview는 Paging 없이 간단히 표시 } } 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 2f2a854ca..2b0c94ef4 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 @@ -1,6 +1,11 @@ package com.neki.android.feature.archive.impl.photo import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore @@ -8,16 +13,36 @@ import com.neki.android.feature.archive.impl.model.SelectMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel -class AllPhotoViewModel @Inject constructor() : ViewModel() { +class AllPhotoViewModel @Inject constructor( + private val photoRepository: PhotoRepository, +) : ViewModel() { + + private val deletedPhotoIds = MutableStateFlow>(emptySet()) + + private val originalPagingData: Flow> = + photoRepository.getPhotosFlow(folderId = null) + .cachedIn(viewModelScope) + + val photoPagingData: Flow> = combine( + originalPagingData, + deletedPhotoIds, + ) { pagingData, deletedIds -> + pagingData.filter { photo -> photo.id !in deletedIds } + } val store: MviIntentStore = mviIntentStore( initialState = AllPhotoState(), onIntent = ::onIntent, - initialFetchData = { store.onIntent(AllPhotoIntent.EnterAllPhotoScreen) }, ) private fun onIntent( @@ -27,7 +52,7 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { postSideEffect: (AllPhotoSideEffect) -> Unit, ) { when (intent) { - AllPhotoIntent.EnterAllPhotoScreen -> fetchInitialData(reduce) + AllPhotoIntent.EnterAllPhotoScreen -> Unit // TopBar Intent AllPhotoIntent.ClickTopBarBackIcon -> handleBackClick(state, reduce, postSideEffect) @@ -55,46 +80,6 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { } } - private fun fetchInitialData(reduce: (AllPhotoState.() -> AllPhotoState) -> Unit) { - val dummyPhotos = listOf( - Photo(id = 1, imageUrl = "https://picsum.photos/seed/all1/400/520", isFavorite = true, date = "2025.01.15"), - Photo(id = 2, imageUrl = "https://picsum.photos/seed/all2/400/680", isFavorite = false, date = "2025.01.15"), - Photo(id = 3, imageUrl = "https://picsum.photos/seed/all3/400/450", isFavorite = true, date = "2025.01.14"), - Photo(id = 4, imageUrl = "https://picsum.photos/seed/all4/400/600", isFavorite = false, date = "2025.01.14"), - Photo(id = 5, imageUrl = "https://picsum.photos/seed/all5/400/550", isFavorite = false, date = "2025.01.13"), - Photo(id = 6, imageUrl = "https://picsum.photos/seed/all6/400/720", isFavorite = true, date = "2025.01.13"), - Photo(id = 7, imageUrl = "https://picsum.photos/seed/all7/400/480", isFavorite = false, date = "2025.01.12"), - Photo(id = 8, imageUrl = "https://picsum.photos/seed/all8/400/650", isFavorite = false, date = "2025.01.12"), - Photo(id = 9, imageUrl = "https://picsum.photos/seed/all9/400/500", isFavorite = true, date = "2025.01.11"), - Photo(id = 10, imageUrl = "https://picsum.photos/seed/all10/400/580", isFavorite = false, date = "2025.01.11"), - Photo(id = 11, imageUrl = "https://picsum.photos/seed/all11/400/700", isFavorite = false, date = "2025.01.10"), - Photo(id = 12, imageUrl = "https://picsum.photos/seed/all12/400/460", isFavorite = true, date = "2025.01.10"), - Photo(id = 13, imageUrl = "https://picsum.photos/seed/all13/400/620", isFavorite = false, date = "2025.01.09"), - Photo(id = 14, imageUrl = "https://picsum.photos/seed/all14/400/540", isFavorite = false, date = "2025.01.09"), - Photo(id = 15, imageUrl = "https://picsum.photos/seed/all15/400/690", isFavorite = true, date = "2025.01.08"), - Photo(id = 16, imageUrl = "https://picsum.photos/seed/all16/400/470", isFavorite = false, date = "2025.01.08"), - Photo(id = 17, imageUrl = "https://picsum.photos/seed/all17/400/610", isFavorite = false, date = "2025.01.07"), - Photo(id = 18, imageUrl = "https://picsum.photos/seed/all18/400/530", isFavorite = true, date = "2025.01.07"), - Photo(id = 19, imageUrl = "https://picsum.photos/seed/all19/400/670", isFavorite = false, date = "2025.01.06"), - Photo(id = 20, imageUrl = "https://picsum.photos/seed/all20/400/490", isFavorite = false, date = "2025.01.06"), - Photo(id = 21, imageUrl = "https://picsum.photos/seed/all21/400/640", isFavorite = true, date = "2025.01.05"), - Photo(id = 22, imageUrl = "https://picsum.photos/seed/all22/400/560", isFavorite = false, date = "2025.01.05"), - Photo(id = 23, imageUrl = "https://picsum.photos/seed/all23/400/710", isFavorite = false, date = "2025.01.04"), - Photo(id = 24, imageUrl = "https://picsum.photos/seed/all24/400/440", isFavorite = true, date = "2025.01.04"), - Photo(id = 25, imageUrl = "https://picsum.photos/seed/all25/400/590", isFavorite = false, date = "2025.01.03"), - ).toImmutableList() - - val sortedPhotos = dummyPhotos.sortedByDescending { it.date }.toImmutableList() - - reduce { - copy( - photos = dummyPhotos, - sortedDescendingPhotos = sortedPhotos, - showingPhotos = sortedPhotos, - ) - } - } - private fun handleBackClick( state: AllPhotoState, reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, @@ -116,15 +101,7 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, ) { reduce { - copy( - isFavoriteChipSelected = !isFavoriteChipSelected, - showingPhotos = if (!state.isFavoriteChipSelected) { - showingPhotos.filter { it.isFavorite } - } else { - if (state.selectedPhotoFilter == PhotoFilter.NEWEST) sortedDescendingPhotos - else photos.sortedBy { it.date } - }.toImmutableList(), - ) + copy(isFavoriteChipSelected = !isFavoriteChipSelected) } } @@ -134,14 +111,9 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { postSideEffect: (AllPhotoSideEffect) -> Unit, ) { reduce { - val sortedPhotos = when (filter) { - PhotoFilter.NEWEST -> sortedDescendingPhotos.ifEmpty { photos.sortedByDescending { it.date } } - PhotoFilter.OLDEST -> photos.sortedBy { it.date } - }.filter { if (isFavoriteChipSelected) it.isFavorite else true }.toImmutableList() copy( isShowFilterDialog = false, selectedPhotoFilter = filter, - showingPhotos = sortedPhotos, ) } postSideEffect(AllPhotoSideEffect.ScrollToTop) @@ -193,15 +165,30 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 선택해주세요.")) return } - // TODO: Delete selected photos from repository - reduce { - copy( - photos = photos.filter { photo -> selectedPhotos.none { it.id == photo.id } }.toImmutableList(), - selectedPhotos = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteDialog = false, - ) + + viewModelScope.launch { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + reduce { copy(isLoading = true) } + + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteDialog = false, + isLoading = false, + ) + } + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } } - postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 삭제했어요")) } } From f2ca191c23cc01d14d682151199e358c7d43569a Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:01:34 +0900 Subject: [PATCH 16/39] =?UTF-8?q?[refactor]=20#59:=20Paging=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EB=93=9C=20=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paging의 `initialLoadSize`를 `PAGE_SIZE`로 설정하여 초기 로딩 성능을 개선합니다. --- .../core/data/repository/impl/PhotoRepositoryImpl.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 24543e099..77aa0597a 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 @@ -78,10 +78,11 @@ class PhotoRepositoryImpl @Inject constructor( return Pager( config = PagingConfig( pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE, enablePlaceholders = false, ), - pagingSourceFactory = { PhotoPagingSource(photoService, folderId) } + pagingSourceFactory = { PhotoPagingSource(photoService, folderId) }, ).flow } @@ -89,10 +90,11 @@ class PhotoRepositoryImpl @Inject constructor( return Pager( config = PagingConfig( pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE, enablePlaceholders = false, ), - pagingSourceFactory = { FavoritePhotoPagingSource(photoService, sortOrder) } + pagingSourceFactory = { FavoritePhotoPagingSource(photoService, sortOrder) }, ).flow } } From 3e2d451e04aaff0a210de95672db3f3b82db6098 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:17:13 +0900 Subject: [PATCH 17/39] =?UTF-8?q?[refactor]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=8B=9C=20albumId=20=EB=8C=80=EC=8B=A0=20AlbumPreview=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앨범 상세 화면으로 이동할 때 `albumId`만 전달하던 방식에서 `AlbumPreview` 객체 전체를 전달하도록 수정합니다. 이를 통해 앨범 제목(`title`)을 상세 화면 ViewModel에 직접 주입할 수 있게 되어, 불필요한 데이터 로딩 과정을 제거하고 화면 간 데이터 전달 방식을 일관성 있게 개선합니다. - `navigateToAlbumDetail` 함수의 파라미터에 `title`을 추가합니다. - 앨범 아이템 클릭 시 `albumId` 대신 `AlbumPreview` 객체를 전달하도록 `ClickAlbumItem` 인텐트를 수정합니다. - `AlbumDetailViewModel` 생성 시 `title`을 주입받아 초기 상태를 설정합니다. --- .../feature/archive/api/ArchiveNavKey.kt | 10 ++++-- .../archive/impl/album/AllAlbumContract.kt | 4 +-- .../archive/impl/album/AllAlbumScreen.kt | 8 ++--- .../archive/impl/album/AllAlbumViewModel.kt | 12 +++---- .../impl/album_detail/AlbumDetailViewModel.kt | 4 ++- .../archive/impl/main/ArchiveMainContract.kt | 4 +-- .../archive/impl/main/ArchiveMainScreen.kt | 8 ++--- .../archive/impl/main/ArchiveMainViewModel.kt | 2 +- .../main/component/ArchiveMainAlbumList.kt | 4 +-- .../impl/navigation/ArchiveEntryProvider.kt | 32 ++++++++++++++++--- .../impl/album/UploadAlbumContract.kt | 6 ++-- .../impl/album/UploadAlbumScreen.kt | 13 +++++--- .../impl/album/UploadAlbumViewModel.kt | 14 ++++---- .../impl/di/PhotoUploadEntryProvider.kt | 4 +-- 14 files changed, 78 insertions(+), 47 deletions(-) diff --git a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt index c1eab1603..a8e57c9fc 100644 --- a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt @@ -17,7 +17,11 @@ sealed interface ArchiveNavKey : NavKey { data object AllAlbum : ArchiveNavKey @Serializable - data class AlbumDetail(val isFavorite: Boolean, val albumId: Long) : ArchiveNavKey + data class AlbumDetail( + val isFavorite: Boolean, + val title: String, + val albumId: Long, + ) : ArchiveNavKey @Serializable data class PhotoDetail(val photo: Photo) : ArchiveNavKey @@ -35,8 +39,8 @@ fun Navigator.navigateToAllAlbum() { navigate(ArchiveNavKey.AllAlbum) } -fun Navigator.navigateToAlbumDetail(id: Long, isFavorite: Boolean = false) { - navigate(ArchiveNavKey.AlbumDetail(isFavorite, id)) +fun Navigator.navigateToAlbumDetail(id: Long, title: String = "", isFavorite: Boolean = false) { + navigate(ArchiveNavKey.AlbumDetail(isFavorite, title, id)) } fun Navigator.navigateToPhotoDetail(photo: Photo) { 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 ff46cc520..62771d983 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 @@ -41,7 +41,7 @@ sealed interface AllAlbumIntent { // Album Intent data object ClickFavoriteAlbum : AllAlbumIntent - data class ClickAlbumItem(val albumId: Long) : AllAlbumIntent + data class ClickAlbumItem(val album: AlbumPreview) : AllAlbumIntent // Add Album BottomSheet Intent data object DismissAddAlbumBottomSheet : AllAlbumIntent @@ -56,6 +56,6 @@ sealed interface AllAlbumIntent { sealed interface AllAlbumSideEffect { data object NavigateBack : AllAlbumSideEffect data class NavigateToFavoriteAlbum(val albumId: Long) : AllAlbumSideEffect - data class NavigateToAlbumDetail(val albumId: Long) : AllAlbumSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : AllAlbumSideEffect data class ShowToastMessage(val message: String) : 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 f17d00b1b..84e58140f 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,10 +20,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.DevicePreview -import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.AlbumPreview import com.neki.android.core.ui.component.AlbumRowComponent +import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet import com.neki.android.core.ui.component.FavoriteAlbumRowComponent import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.core.ui.toast.NekiToast @@ -38,7 +38,7 @@ internal fun AllAlbumRoute( viewModel: AllAlbumViewModel = hiltViewModel(), navigateBack: () -> Unit, navigateToFavoriteAlbum: (Long) -> Unit, - navigateToAlbumDetail: (Long) -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current @@ -48,7 +48,7 @@ internal fun AllAlbumRoute( when (sideEffect) { AllAlbumSideEffect.NavigateBack -> navigateBack() is AllAlbumSideEffect.NavigateToFavoriteAlbum -> navigateToFavoriteAlbum(sideEffect.albumId) - is AllAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId) + is AllAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) is AllAlbumSideEffect.ShowToastMessage -> { nekiToast.showToast(text = sideEffect.message) } @@ -108,7 +108,7 @@ internal fun AllAlbumScreen( album = album, isSelectable = uiState.selectMode == SelectMode.SELECTING, isSelected = isSelected, - onClick = { onIntent(AllAlbumIntent.ClickAlbumItem(album.id)) }, + onClick = { onIntent(AllAlbumIntent.ClickAlbumItem(album)) }, ) } } 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 26f772e47..56e18dea5 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 @@ -65,7 +65,7 @@ class AllAlbumViewModel @Inject constructor( AllAlbumSideEffect.NavigateToFavoriteAlbum(state.favoriteAlbum.id), ) - is AllAlbumIntent.ClickAlbumItem -> handleAlbumClick(intent.albumId, state, reduce, postSideEffect) + is AllAlbumIntent.ClickAlbumItem -> handleAlbumClick(intent.album, state, reduce, postSideEffect) // Add Album BottomSheet Intent AllAlbumIntent.DismissAddAlbumBottomSheet -> reduce { copy(isShowAddAlbumBottomSheet = false) } @@ -141,22 +141,22 @@ class AllAlbumViewModel @Inject constructor( } private fun handleAlbumClick( - albumId: Long, + album: AlbumPreview, state: AllAlbumState, reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { when (state.selectMode) { SelectMode.DEFAULT -> { - postSideEffect(AllAlbumSideEffect.NavigateToAlbumDetail(albumId)) + postSideEffect(AllAlbumSideEffect.NavigateToAlbumDetail(album.id, album.title)) } SelectMode.SELECTING -> { - val album = state.albums.find { it.id == albumId } ?: return - val isSelected = state.selectedAlbums.any { it.id == albumId } + val album = state.albums.find { it.id == album.id } ?: return + val isSelected = state.selectedAlbums.any { it.id == album.id } if (isSelected) { reduce { - copy(selectedAlbums = selectedAlbums.filter { it.id != albumId }.toImmutableList()) + copy(selectedAlbums = selectedAlbums.filter { it.id != album.id }.toImmutableList()) } } else { reduce { 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 d92838d4d..db358e042 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 @@ -26,13 +26,14 @@ import timber.log.Timber @HiltViewModel(assistedFactory = AlbumDetailViewModel.Factory::class) class AlbumDetailViewModel @AssistedInject constructor( @Assisted private val id: Long, + @Assisted private val title: String, @Assisted private val isFavoriteAlbum: Boolean, private val photoRepository: PhotoRepository, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(id: Long, isFavoriteAlbum: Boolean): AlbumDetailViewModel + fun create(id: Long, title: String, isFavoriteAlbum: Boolean): AlbumDetailViewModel } private val deletedPhotoIds = MutableStateFlow>(emptySet()) @@ -54,6 +55,7 @@ class AlbumDetailViewModel @AssistedInject constructor( val store: MviIntentStore = mviIntentStore( initialState = AlbumDetailState( + title = title, isFavoriteAlbum = isFavoriteAlbum, ), onIntent = ::onIntent, 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 08998d1b6..9297cb083 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 @@ -47,7 +47,7 @@ sealed interface ArchiveMainIntent { // Album Intent data object ClickAllAlbumText : ArchiveMainIntent data object ClickFavoriteAlbum : ArchiveMainIntent - data class ClickAlbumItem(val albumId: Long) : ArchiveMainIntent + data class ClickAlbumItem(val albumId: Long, val albumTitle: String) : ArchiveMainIntent // Photo Intent data object ClickAllPhotoText : ArchiveMainIntent @@ -64,7 +64,7 @@ sealed interface ArchiveMainSideEffect { data class NavigateToUploadAlbumWithQRScan(val imageUrl: String) : ArchiveMainSideEffect data object NavigateToAllAlbum : ArchiveMainSideEffect data class NavigateToFavoriteAlbum(val albumId: Long) : ArchiveMainSideEffect - data class NavigateToAlbumDetail(val albumId: Long) : ArchiveMainSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : ArchiveMainSideEffect data object NavigateToAllPhoto : ArchiveMainSideEffect data class NavigateToPhotoDetail(val photo: Photo) : ArchiveMainSideEffect diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt index d6dba07de..6536b9fbe 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt @@ -59,7 +59,7 @@ internal fun ArchiveMainRoute( navigateToUploadAlbumWithQRScan: (String) -> Unit, navigateToAllAlbum: () -> Unit, navigateToFavoriteAlbum: (Long) -> Unit, - navigateToAlbumDetail: (Long) -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, navigateToAllPhoto: () -> Unit, navigateToPhotoDetail: (Photo) -> Unit, ) { @@ -82,7 +82,7 @@ internal fun ArchiveMainRoute( is ArchiveMainSideEffect.NavigateToUploadAlbumWithQRScan -> navigateToUploadAlbumWithQRScan(sideEffect.imageUrl) ArchiveMainSideEffect.NavigateToAllAlbum -> navigateToAllAlbum() is ArchiveMainSideEffect.NavigateToFavoriteAlbum -> navigateToFavoriteAlbum(sideEffect.albumId) - is ArchiveMainSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId) + is ArchiveMainSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) ArchiveMainSideEffect.NavigateToAllPhoto -> navigateToAllPhoto() is ArchiveMainSideEffect.NavigateToPhotoDetail -> navigateToPhotoDetail(sideEffect.photo) ArchiveMainSideEffect.ScrollToTop -> lazyState.animateScrollToItem(0) @@ -126,7 +126,7 @@ internal fun ArchiveMainScreen( lazyState = lazyState, onClickShowAllAlbum = { onIntent(ArchiveMainIntent.ClickAllAlbumText) }, onClickFavoriteAlbum = { onIntent(ArchiveMainIntent.ClickFavoriteAlbum) }, - onClickAlbumItem = { onIntent(ArchiveMainIntent.ClickAlbumItem(it)) }, + onClickAlbumItem = { onIntent(ArchiveMainIntent.ClickAlbumItem(it.id, it.title)) }, onClickShowAllPhoto = { onIntent(ArchiveMainIntent.ClickAllPhotoText) }, onClickPhotoItem = { photo -> onIntent(ArchiveMainIntent.ClickPhotoItem(photo)) }, ) @@ -189,7 +189,7 @@ private fun ArchiveMainContent( lazyState: LazyStaggeredGridState, onClickShowAllAlbum: () -> Unit, onClickFavoriteAlbum: () -> Unit, - onClickAlbumItem: (Long) -> Unit, + onClickAlbumItem: (AlbumPreview) -> Unit, onClickShowAllPhoto: () -> Unit, onClickPhotoItem: (Photo) -> Unit, modifier: Modifier = Modifier, 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 c756fddd3..6aa583db2 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 @@ -106,7 +106,7 @@ class ArchiveMainViewModel @Inject constructor( // Album Intent ArchiveMainIntent.ClickAllAlbumText -> postSideEffect(ArchiveMainSideEffect.NavigateToAllAlbum) ArchiveMainIntent.ClickFavoriteAlbum -> postSideEffect(ArchiveMainSideEffect.NavigateToFavoriteAlbum(-1L)) - is ArchiveMainIntent.ClickAlbumItem -> postSideEffect(ArchiveMainSideEffect.NavigateToAlbumDetail(intent.albumId)) + is ArchiveMainIntent.ClickAlbumItem -> postSideEffect(ArchiveMainSideEffect.NavigateToAlbumDetail(intent.albumId, intent.albumTitle)) // Photo Intent ArchiveMainIntent.ClickAllPhotoText -> postSideEffect(ArchiveMainSideEffect.NavigateToAllPhoto) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt index 0e8bd4215..c897679e9 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt @@ -53,7 +53,7 @@ internal fun ArchiveMainAlbumList( modifier: Modifier = Modifier, albumList: ImmutableList = persistentListOf(), onClickFavoriteAlbum: () -> Unit = {}, - onClickAlbumItem: (Long) -> Unit = {}, + onClickAlbumItem: (AlbumPreview) -> Unit = {}, ) { LazyRow( modifier = modifier, @@ -77,7 +77,7 @@ internal fun ArchiveMainAlbumList( title = album.title, photoCount = album.photoCount, thumbnailImage = album.thumbnailUrl, - onClick = { onClickAlbumItem(album.id) }, + onClick = { onClickAlbumItem(album) }, ) } } 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 9816a1cd2..92549bada 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 @@ -66,8 +66,19 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { navigateToUploadAlbumWithGallery = navigator::navigateToUploadAlbum, navigateToUploadAlbumWithQRScan = navigator::navigateToUploadAlbum, navigateToAllAlbum = navigator::navigateToAllAlbum, - navigateToFavoriteAlbum = { id -> navigator.navigateToAlbumDetail(isFavorite = true, id = id) }, - navigateToAlbumDetail = { id -> navigator.navigateToAlbumDetail(isFavorite = false, id = id) }, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail( + id = id, + isFavorite = true, + ) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail( + id = id, + title = title, + isFavorite = false, + ) + }, navigateToAllPhoto = navigator::navigateToAllPhoto, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) @@ -83,15 +94,26 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { entry { AllAlbumRoute( navigateBack = navigator::goBack, - navigateToFavoriteAlbum = { id -> navigator.navigateToAlbumDetail(isFavorite = true, id = id) }, - navigateToAlbumDetail = { id -> navigator.navigateToAlbumDetail(isFavorite = false, id = id) }, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail( + id = id, + isFavorite = true, + ) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail( + id = id, + title = title, + isFavorite = false, + ) + }, ) } entry { key -> AlbumDetailRoute( viewModel = hiltViewModel( creationCallback = { factory -> - factory.create(key.albumId, key.isFavorite) + factory.create(key.albumId, key.title, key.isFavorite) }, ), navigateBack = navigator::goBack, diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt index 6afdee0d9..a193546dc 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt @@ -13,7 +13,7 @@ data class UploadAlbumState( val selectedUris: ImmutableList = persistentListOf(), val favoriteAlbum: AlbumPreview = AlbumPreview(), val albums: ImmutableList = persistentListOf(), - val selectedAlbumIds: PersistentList = persistentListOf(), + val selectedAlbums: PersistentList = persistentListOf(), ) { val count: Int get() = if (imageUrl == null) selectedUris.size else 1 @@ -29,11 +29,11 @@ sealed interface UploadAlbumIntent { data object ClickUploadButton : UploadAlbumIntent // Album Intent - data class ClickAlbumItem(val albumId: Long) : UploadAlbumIntent + data class ClickAlbumItem(val album: AlbumPreview) : UploadAlbumIntent } sealed interface UploadAlbumSideEffect { data object NavigateBack : UploadAlbumSideEffect - data class NavigateToAlbumDetail(val albumId: Long) : UploadAlbumSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : UploadAlbumSideEffect data class ShowToastMessage(val message: String) : UploadAlbumSideEffect } diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt index 09cb62eee..0c46218ce 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt @@ -31,7 +31,7 @@ import kotlinx.collections.immutable.persistentListOf internal fun UploadAlbumRoute( viewModel: UploadAlbumViewModel = hiltViewModel(), navigateBack: () -> Unit, - navigateToAlbumDetail: (Long) -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current @@ -40,7 +40,7 @@ internal fun UploadAlbumRoute( viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { UploadAlbumSideEffect.NavigateBack -> navigateBack() - is UploadAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId) + is UploadAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) is UploadAlbumSideEffect.ShowToastMessage -> nekiToast.showToast(sideEffect.message) } } @@ -81,12 +81,12 @@ internal fun UploadAlbumScreen( items = uiState.albums, key = { album -> album.id }, ) { album -> - val isSelected = uiState.selectedAlbumIds.any { it == album.id } + val isSelected = uiState.selectedAlbums.any { it.id == album.id } AlbumRowComponent( album = album, isSelectable = true, isSelected = isSelected, - onClick = { onIntent(UploadAlbumIntent.ClickAlbumItem(album.id)) }, + onClick = { onIntent(UploadAlbumIntent.ClickAlbumItem(album)) }, ) } } @@ -127,7 +127,10 @@ private fun UploadAlbumScreenSelectingPreview() { AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), ), - selectedAlbumIds = persistentListOf(1L, 3L), + selectedAlbums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + ), ), ) } diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt index 4d1c6860a..f48a9c92c 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt @@ -60,10 +60,10 @@ class UploadAlbumViewModel @AssistedInject constructor( is UploadAlbumIntent.ClickAlbumItem -> { reduce { copy( - selectedAlbumIds = if (state.selectedAlbumIds.any { it == intent.albumId }) { - selectedAlbumIds.remove(intent.albumId) + selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) { + selectedAlbums.remove(intent.album) } else { - selectedAlbumIds.add(intent.albumId) + selectedAlbums.add(intent.album) }.toPersistentList(), ) } @@ -110,11 +110,11 @@ class UploadAlbumViewModel @AssistedInject constructor( reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, postSideEffect: (UploadAlbumSideEffect) -> Unit, ) { - val firstAlbumId = state.selectedAlbumIds.firstOrNull() ?: return + val firstAlbum = state.selectedAlbums.firstOrNull() ?: return val onSuccessAction = { reduce { copy(isLoading = false) } postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지를 추가했어요")) - postSideEffect(UploadAlbumSideEffect.NavigateToAlbumDetail(firstAlbumId)) + postSideEffect(UploadAlbumSideEffect.NavigateToAlbumDetail(firstAlbum.id, firstAlbum.title)) } val onFailureAction: (Throwable) -> Unit = { error -> Timber.e(error) @@ -125,7 +125,7 @@ class UploadAlbumViewModel @AssistedInject constructor( if (state.uploadType == UploadType.SINGLE) { uploadSingleImage( imageUrl = state.imageUrl ?: return, - albumId = firstAlbumId, + albumId = firstAlbum.id, reduce = reduce, onSuccessAction = onSuccessAction, onFailureAction = onFailureAction, @@ -133,7 +133,7 @@ class UploadAlbumViewModel @AssistedInject constructor( } else { uploadMultipleImages( imageUris = state.selectedUris, - albumId = firstAlbumId, + albumId = firstAlbum.id, reduce = reduce, onSuccessAction = onSuccessAction, onFailureAction = onFailureAction, diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt index c2a7b90ef..977d2ca4b 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt @@ -46,9 +46,9 @@ private fun EntryProviderScope.photoUploadEntry(navigator: Navigator) { }, ), navigateBack = navigator::goBack, - navigateToAlbumDetail = { id -> + navigateToAlbumDetail = { id, title -> navigator.remove(key) - navigator.navigateToAlbumDetail(id = id) + navigator.navigateToAlbumDetail(id = id, title = title) }, ) } From 1fd2acd601d69a07046a553d52ecdffba713afb8 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:20:53 +0900 Subject: [PATCH 18/39] =?UTF-8?q?[feat]=20#69:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=20=EC=95=A8=EB=B2=94=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `folderRepository.createFolder` API를 호출하여 새로운 앨범을 생성하는 기능을 구현합니다. - API 호출 성공 시, 앨범 목록을 갱신하고 "새로운 앨범을 추가했어요"라는 토스트 메시지를 표시합니다. - API 호출 실패 시, "앨범 추가에 실패했어요"라는 토스트 메시지를 표시하고 에러 로그를 기록합니다. --- .../archive/impl/album/AllAlbumViewModel.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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 56e18dea5..850bb72ec 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 @@ -172,14 +172,23 @@ class AllAlbumViewModel @Inject constructor( reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { - // TODO: Add album to repository - reduce { - copy( - isShowAddAlbumBottomSheet = false, - albums = (albums + AlbumPreview(id = albums.size.toLong(), title = albumName)).toImmutableList(), - ) + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { folderId -> + val newAlbum = AlbumPreview(id = folderId, title = albumName) + + reduce { copy(albums = (albums + newAlbum).toImmutableList()) } + postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + Timber.d("folderId: $folderId") + } + .onFailure { error -> + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) + Timber.e(error) + + } + reduce { copy(isShowAddAlbumBottomSheet = false) } } - postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + } private fun handleDeleteConfirm( From 5b2b0f1ab410399195c782f0df2e4f1990703dea Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:21:22 +0900 Subject: [PATCH 19/39] =?UTF-8?q?[feat]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20fetch=20a?= =?UTF-8?q?pi=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/archive/impl/album/AllAlbumViewModel.kt | 7 ++----- .../feature/archive/impl/main/ArchiveMainViewModel.kt | 11 +++++------ 2 files changed, 7 insertions(+), 11 deletions(-) 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 850bb72ec..4ee38ccf2 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 @@ -174,12 +174,9 @@ class AllAlbumViewModel @Inject constructor( ) { viewModelScope.launch { folderRepository.createFolder(name = albumName) - .onSuccess { folderId -> - val newAlbum = AlbumPreview(id = folderId, title = albumName) - - reduce { copy(albums = (albums + newAlbum).toImmutableList()) } + .onSuccess { + fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) - Timber.d("folderId: $folderId") } .onFailure { error -> postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) 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 6aa583db2..c19cfe7b2 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 @@ -241,19 +241,18 @@ class ArchiveMainViewModel @Inject constructor( reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, postSideEffect: (ArchiveMainSideEffect) -> Unit, ) { - // TODO: Add album to repository viewModelScope.launch { folderRepository.createFolder(name = albumName) - .onSuccess { folderId -> + .onSuccess { fetchFolders(reduce) - Timber.d("folderId: $folderId") + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } .onFailure { error -> + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) Timber.e(error) + } + reduce { copy(isShowAddAlbumBottomSheet = false) } } - - reduce { copy(isShowAddAlbumBottomSheet = false) } - postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } } From 82c88979c352331acd2f98b9aac345d2545e1be4 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:29:45 +0900 Subject: [PATCH 20/39] =?UTF-8?q?[refactor]=20#69:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20=EC=9D=91=EB=8B=B5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EB=B0=98=ED=99=98=EA=B0=92=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 폴더 생성 API의 응답 타입이 `BasicResponse`에서 `BasicNullableResponse`로 변경되었습니다. 이에 따라 `FolderRepository`의 `createFolder` 함수의 반환 타입을 `Result`에서 `Result`으로 수정하여, 더 이상 폴더 ID를 반환하지 않도록 변경했습니다. --- .../neki/android/core/dataapi/repository/FolderRepository.kt | 2 +- .../com/neki/android/core/data/remote/api/FolderService.kt | 4 +++- .../core/data/remote/model/response/CreateFolderResponse.kt | 1 + .../android/core/data/repository/impl/FolderRepositoryImpl.kt | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt index c02f4881a..26b47f613 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -4,5 +4,5 @@ import com.neki.android.core.model.AlbumPreview interface FolderRepository { suspend fun getFolders(): Result> - suspend fun createFolder(name: String): Result + suspend fun createFolder(name: String): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt index a0cc3ca09..f472c6aaa 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -1,6 +1,8 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.remote.model.request.DeleteFolderRequest +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.CreateFolderResponse import com.neki.android.core.data.remote.model.response.FolderResponse @@ -20,7 +22,7 @@ class FolderService @Inject constructor( } // 폴더 생성 - suspend fun createFolder(requestBody: CreateFolderRequest): BasicResponse { + suspend fun createFolder(requestBody: CreateFolderRequest): BasicNullableResponse { return client.post("/api/folders") { setBody(requestBody) }.body() } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt index 9f1a065d9..e8a5d88e8 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt @@ -3,6 +3,7 @@ package com.neki.android.core.data.remote.model.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// TODO: 추후 API 스펙 업데이트 시 제거 @Serializable data class CreateFolderResponse( @SerialName("folderId") val folderId: Long, diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt index c40860654..213ae0d70 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.neki.android.core.data.repository.impl import com.neki.android.core.data.remote.api.FolderService import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.remote.model.request.DeleteFolderRequest import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.model.AlbumPreview @@ -14,7 +15,7 @@ class FolderRepositoryImpl @Inject constructor( folderService.getFolders().data.toModels() } - override suspend fun createFolder(name: String): Result = runSuspendCatching { + override suspend fun createFolder(name: String): Result = runSuspendCatching { folderService.createFolder( requestBody = CreateFolderRequest(name = name), ).data.folderId From 7d9cea1f0286a76504ce47aea53f913886651428 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:32:46 +0900 Subject: [PATCH 21/39] =?UTF-8?q?[feat]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택한 앨범을 삭제하는 기능을 구현합니다. - `FolderService`에 `deleteFolder` API 함수를 추가합니다. - `FolderRepository` 및 `FolderRepositoryImpl`에 `deleteFolder` 함수를 추가하고, `DeleteFolderRequest`를 사용하여 API를 호출하도록 구현합니다. - `AllAlbumViewModel`에서 선택된 앨범들의 ID를 사용하여 `folderRepository.deleteFolder`를 호출하고, 성공 시 앨범 목록을 다시 불러오도록 수정합니다. --- .../dataapi/repository/FolderRepository.kt | 1 + .../core/data/remote/api/FolderService.kt | 5 ++++ .../model/request/DeleteFolderRequest.kt | 9 ++++++ .../repository/impl/FolderRepositoryImpl.kt | 8 ++++- .../archive/impl/album/AllAlbumViewModel.kt | 30 +++++++++++++------ 5 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt index 26b47f613..ff33722d8 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -5,4 +5,5 @@ import com.neki.android.core.model.AlbumPreview interface FolderRepository { suspend fun getFolders(): Result> suspend fun createFolder(name: String): Result + suspend fun deleteFolder(id: List): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt index f472c6aaa..cc776eaa8 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -25,4 +25,9 @@ class FolderService @Inject constructor( suspend fun createFolder(requestBody: CreateFolderRequest): BasicNullableResponse { return client.post("/api/folders") { setBody(requestBody) }.body() } + + // 폴더 삭제 + suspend fun deleteFolder(requestBody: DeleteFolderRequest): BasicNullableResponse { + return client.post("/api/folders") { setBody(requestBody) }.body() + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt new file mode 100644 index 000000000..7ccc0c03b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.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 DeleteFolderRequest( + @SerialName("folderIds") val folderIds: List, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt index 213ae0d70..ab2504fbd 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -18,6 +18,12 @@ class FolderRepositoryImpl @Inject constructor( override suspend fun createFolder(name: String): Result = runSuspendCatching { folderService.createFolder( requestBody = CreateFolderRequest(name = name), - ).data.folderId + ).data + } + + override suspend fun deleteFolder(id: List): Result = runSuspendCatching { + folderService.deleteFolder( + requestBody = DeleteFolderRequest(folderIds = id), + ).data } } 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 4ee38ccf2..9d23cd89c 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 @@ -193,15 +193,27 @@ class AllAlbumViewModel @Inject constructor( reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { - // TODO: Delete albums from repository based on selectedDeleteOption - reduce { - copy( - albums = albums.filter { album -> selectedAlbums.none { it.id == album.id } }.toImmutableList(), - selectedAlbums = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteAlbumBottomSheet = false, - ) + // TODO: 삭제 타입에 따라 핸들링 + viewModelScope.launch { + val selectedAlbumIds = state.selectedAlbums.map { it.id } + + folderRepository.deleteFolder(selectedAlbumIds) + .onSuccess { + fetchFolders(reduce) + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error, "사진 삭제 실패") + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 삭제에 실패했어요")) + } + reduce { + copy( + selectedAlbums = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteAlbumBottomSheet = false, + ) + } } - postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 삭제했어요")) + } } From b3613cb643839f25a04d5f73de2ec3703ecaaeea Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:39:15 +0900 Subject: [PATCH 22/39] =?UTF-8?q?[refactor]=20#59:=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B4=80=EB=A0=A8=20=EC=83=81=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사진 그리드 레이아웃에 사용되는 상수의 이름을 더 명확하게 변경하고 이를 각 화면에 적용합니다. - `ARCHIVE_LAYOUT_HORIZONTAL_PADDING` -> `PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING` - `ARCHIVE_LAYOUT_BOTTOM_PADDING` -> `PHOTO_GRAY_LAYOUT_BOTTOM_PADDING` - `PHOTO_GRID_LAYOUT_TOP_PADDING` 상수 추가 --- .../archive/impl/album_detail/AlbumDetailScreen.kt | 10 +++++++--- .../feature/archive/impl/const/ArchiveConst.kt | 5 +++-- .../feature/archive/impl/main/ArchiveMainScreen.kt | 4 ++-- .../feature/archive/impl/photo/AllPhotoScreen.kt | 13 +++++++------ 4 files changed, 19 insertions(+), 13 deletions(-) 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 a44f84685..aad0acc6e 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 @@ -38,7 +38,9 @@ import com.neki.android.feature.archive.impl.album_detail.component.EmptyContent import com.neki.android.feature.archive.impl.component.DeletePhotoDialog import com.neki.android.feature.archive.impl.component.SelectablePhotoItem import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING -import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_TOP_PADDING import com.neki.android.feature.archive.impl.model.SelectMode import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar import com.neki.android.feature.archive.impl.util.ImageDownloader @@ -122,8 +124,10 @@ internal fun AlbumDetailScreen( columns = StaggeredGridCells.Fixed(2), state = lazyState, contentPadding = PaddingValues( - horizontal = ARCHIVE_LAYOUT_HORIZONTAL_PADDING.dp, - vertical = 8.dp, + top = PHOTO_GRID_LAYOUT_TOP_PADDING.dp, + start = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + end = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, ), verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt index bafe3a628..702f1d1b4 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt @@ -3,8 +3,9 @@ package com.neki.android.feature.archive.impl.const internal object ArchiveConst { // Layout internal const val ARCHIVE_ROW_TEXT_BUTTON_PADDING = 12 - internal const val ARCHIVE_LAYOUT_HORIZONTAL_PADDING = 20 - internal const val ARCHIVE_LAYOUT_BOTTOM_PADDING = 76 + internal const val PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING = 20 + internal const val PHOTO_GRID_LAYOUT_TOP_PADDING = 8 + internal const val PHOTO_GRAY_LAYOUT_BOTTOM_PADDING = 76 internal const val ARCHIVE_GRID_ITEM_SPACING = 12 // Corner Radius diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt index 6536b9fbe..069f9c1a4 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt @@ -40,7 +40,7 @@ import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.archive.impl.component.AddAlbumBottomSheet import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING -import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING import com.neki.android.feature.archive.impl.main.component.ArchiveMainAlbumList import com.neki.android.feature.archive.impl.main.component.ArchiveMainPhotoItem import com.neki.android.feature.archive.impl.main.component.ArchiveMainTitleRow @@ -204,7 +204,7 @@ private fun ArchiveMainContent( contentPadding = PaddingValues( start = 20.dp, end = 20.dp, - bottom = ARCHIVE_LAYOUT_BOTTOM_PADDING.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, ), verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), 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 32be5e19a..0c9e875b9 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 @@ -42,8 +42,9 @@ import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.archive.impl.component.DeletePhotoDialog import com.neki.android.feature.archive.impl.component.SelectablePhotoItem import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING -import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_BOTTOM_PADDING -import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_TOP_PADDING import com.neki.android.feature.archive.impl.model.SelectMode import com.neki.android.feature.archive.impl.photo.component.AllPhotoFilterBar import com.neki.android.feature.archive.impl.photo.component.AllPhotoTopBar @@ -139,10 +140,10 @@ internal fun AllPhotoScreen( columns = StaggeredGridCells.Fixed(2), state = lazyState, contentPadding = PaddingValues( - top = if (uiState.selectMode == SelectMode.SELECTING) ARCHIVE_GRID_ITEM_SPACING.dp else topPadding + ARCHIVE_GRID_ITEM_SPACING.dp, - start = ARCHIVE_LAYOUT_HORIZONTAL_PADDING.dp, - end = ARCHIVE_LAYOUT_HORIZONTAL_PADDING.dp, - bottom = ARCHIVE_LAYOUT_BOTTOM_PADDING.dp, + top = if (uiState.selectMode == SelectMode.SELECTING) PHOTO_GRID_LAYOUT_TOP_PADDING.dp else topPadding + PHOTO_GRID_LAYOUT_TOP_PADDING.dp, + start = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + end = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, ), verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), From 84db147626c7d274442e2a1259b224b1c38d114f Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:43:26 +0900 Subject: [PATCH 23/39] =?UTF-8?q?[refactor]=20#69=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=EC=97=90=EC=84=9C=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/album_detail/AlbumDetailViewModel.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) 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 db358e042..12124d9be 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 @@ -173,8 +173,9 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + viewModelScope.launch { - val selectedPhotoIds = state.selectedPhotos.map { it.id } reduce { copy(isLoading = true) } photoRepository.deletePhoto(photoIds = selectedPhotoIds) @@ -206,21 +207,28 @@ class AlbumDetailViewModel @AssistedInject constructor( ) { val selectedPhotoIds = state.selectedPhotos.map { it.id } - // UI에서 즉시 필터링 - deletedPhotoIds.update { it + selectedPhotoIds.toSet() } - - reduce { - copy( - selectedPhotos = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteBottomSheet = false, - ) - } + viewModelScope.launch { + reduce { copy(isLoading = true) } - val message = when (state.selectedDeleteOption) { - PhotoDeleteOption.REMOVE_FROM_ALBUM -> "앨범에서 사진을 제거했어요" - PhotoDeleteOption.REMOVE_FROM_ALL -> "사진을 삭제했어요" + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteDialog = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } } - postSideEffect(AlbumDetailSideEffect.ShowToastMessage(message)) } } From 447dbb80b745a7ed6619b9b296d62eadbc519f7e Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:54:08 +0900 Subject: [PATCH 24/39] =?UTF-8?q?[feat]=20#69:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=ED=99=94=EB=A9=B4=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 사진 화면에 정렬 순서(최신순, 오래된순) 및 즐겨찾기 필터링 기능을 추가합니다. - `PhotoService`와 `PhotoPagingSource`에 `sortOrder` 파라미터를 추가하여 API 요청 시 정렬 순서를 전달합니다. - `AllPhotoViewModel`에서 `flatMapLatest`를 사용해 필터(`_photoFilter`, `_isFavoriteOnly`) 변경 시 `photoPagingData`가 새로 발행되도록 수정했습니다. - 필터가 변경되면 화면이 최상단으로 스크롤되도록 `ScrollToTop` 사이드 이펙트를 처리합니다. - 스크롤 로직을 개선하여, 페이징 데이터 로드가 완료된 후 스크롤이 실행되도록 보장합니다. --- .../dataapi/repository/PhotoRepository.kt | 5 ++- .../core/data/paging/PhotoPagingSource.kt | 2 + .../core/data/remote/api/PhotoService.kt | 2 + .../archive/impl/photo/AllPhotoViewModel.kt | 38 +++++++++++++++---- 4 files changed, 38 insertions(+), 9 deletions(-) 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 f5c22a00d..af03e789e 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 @@ -31,7 +31,10 @@ interface PhotoRepository { suspend fun getFavoriteSummary(): Result - fun getPhotosFlow(folderId: Long?): Flow> + fun getPhotosFlow( + folderId: Long? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> fun getFavoritePhotosFlow( sortOrder: SortOrder = SortOrder.DESC, diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt index 049423f06..a6b42fa1a 100644 --- a/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt @@ -8,6 +8,7 @@ import com.neki.android.core.model.Photo class PhotoPagingSource( private val photoService: PhotoService, private val folderId: Long?, + private val sortOrder: String, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { @@ -24,6 +25,7 @@ class PhotoPagingSource( folderId = folderId, page = page, size = params.loadSize, + sortOrder = sortOrder, ) val photos = response.data.toModels() val hasNext = response.data.hasNext 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 2e36686a7..051d14236 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 @@ -27,11 +27,13 @@ class PhotoService @Inject constructor( folderId: Long? = null, page: Int = 0, size: Int = 20, + sortOrder: String = "DESC", ): BasicResponse { return client.get("/api/photos") { parameter("folderId", folderId) parameter("page", page) parameter("size", size) + parameter("sortOrder", sortOrder) }.body() } 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 2b0c94ef4..56eeeb652 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 @@ -7,15 +7,18 @@ import androidx.paging.cachedIn import androidx.paging.filter import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import com.neki.android.feature.archive.impl.model.SelectMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -27,10 +30,26 @@ class AllPhotoViewModel @Inject constructor( ) : ViewModel() { private val deletedPhotoIds = MutableStateFlow>(emptySet()) - - private val originalPagingData: Flow> = - photoRepository.getPhotosFlow(folderId = null) - .cachedIn(viewModelScope) + private val _photoFilter = MutableStateFlow(PhotoFilter.NEWEST) + private val _isFavoriteOnly = MutableStateFlow(false) + + @OptIn(ExperimentalCoroutinesApi::class) + private val originalPagingData: Flow> = combine( + _photoFilter, + _isFavoriteOnly, + ) { filter, isFavoriteOnly -> + filter to isFavoriteOnly + }.flatMapLatest { (filter, isFavoriteOnly) -> + val sortOrder = when (filter) { + PhotoFilter.NEWEST -> SortOrder.DESC + PhotoFilter.OLDEST -> SortOrder.ASC + } + if (isFavoriteOnly) { + photoRepository.getFavoritePhotosFlow(sortOrder) + } else { + photoRepository.getPhotosFlow(sortOrder = sortOrder) + } + }.cachedIn(viewModelScope) val photoPagingData: Flow> = combine( originalPagingData, @@ -68,7 +87,7 @@ class AllPhotoViewModel @Inject constructor( // Filter Intent AllPhotoIntent.ClickFilterChip -> reduce { copy(isShowFilterDialog = true) } AllPhotoIntent.DismissFilterPopup -> reduce { copy(isShowFilterDialog = false) } - AllPhotoIntent.ClickFavoriteFilterChip -> handleFavoriteFilter(state, reduce) + AllPhotoIntent.ClickFavoriteFilterChip -> handleFavoriteFilter(state, reduce, postSideEffect) is AllPhotoIntent.ClickFilterPopupRow -> handleFilterRow(intent.filter, reduce, postSideEffect) // Photo Intent @@ -99,10 +118,12 @@ class AllPhotoViewModel @Inject constructor( private fun handleFavoriteFilter( state: AllPhotoState, reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, ) { - reduce { - copy(isFavoriteChipSelected = !isFavoriteChipSelected) - } + val newValue = !state.isFavoriteChipSelected + _isFavoriteOnly.value = newValue + reduce { copy(isFavoriteChipSelected = newValue) } + postSideEffect(AllPhotoSideEffect.ScrollToTop) } private fun handleFilterRow( @@ -110,6 +131,7 @@ class AllPhotoViewModel @Inject constructor( reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, postSideEffect: (AllPhotoSideEffect) -> Unit, ) { + _photoFilter.value = filter reduce { copy( isShowFilterDialog = false, From 597c7f9939b12fa864657ad66f2d5d22b2adbf65 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:54:53 +0900 Subject: [PATCH 25/39] =?UTF-8?q?[refactor]=20#69:=20ScrollToTop=20Effect?= =?UTF-8?q?=20=EC=88=98=EC=8B=A0=20=ED=9B=84,=20LoadState=EA=B0=80=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=90=9C=20=ED=9B=84=EC=97=90,=20scrollToIte?= =?UTF-8?q?m=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/archive/impl/photo/AllPhotoScreen.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 0c9e875b9..f760d4404 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 @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableIntStateOf 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.layout.onSizeChanged @@ -50,7 +51,10 @@ import com.neki.android.feature.archive.impl.photo.component.AllPhotoFilterBar import com.neki.android.feature.archive.impl.photo.component.AllPhotoTopBar import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar import com.neki.android.feature.archive.impl.util.ImageDownloader +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import timber.log.Timber @Composable internal fun AllPhotoRoute( @@ -60,6 +64,7 @@ internal fun AllPhotoRoute( ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() + Timber.d(pagingItems.toString()) val context = LocalContext.current val lazyState = rememberLazyStaggeredGridState() val coroutineScope = rememberCoroutineScope() @@ -68,7 +73,13 @@ internal fun AllPhotoRoute( viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { AllPhotoSideEffect.NavigateBack -> navigateBack() - AllPhotoSideEffect.ScrollToTop -> coroutineScope.launch { lazyState.animateScrollToItem(0) } + AllPhotoSideEffect.ScrollToTop -> coroutineScope.launch { + snapshotFlow { pagingItems.loadState.refresh } + .dropWhile { it is LoadState.NotLoading } // 해당 이벤트 도착이, 새로운 pagingItems 조회보다 빠름. + .first { it is LoadState.NotLoading } + lazyState.scrollToItem(0) + } + is AllPhotoSideEffect.NavigateToPhotoDetail -> navigateToPhotoDetail(sideEffect.photo) is AllPhotoSideEffect.ShowToastMessage -> { nekiToast.showToast(text = sideEffect.message) @@ -112,7 +123,7 @@ internal fun AllPhotoScreen( } } - val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading + val isRefreshing by remember { derivedStateOf { pagingItems.loadState.refresh is LoadState.Loading } } BackHandler(enabled = true) { onIntent(AllPhotoIntent.OnBackPressed) From a3671f24ac9336d6370a107161ed34ea0ac67262 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:56:46 +0900 Subject: [PATCH 26/39] =?UTF-8?q?[fix]=20#64:=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EA=B0=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/archive/impl/main/ArchiveMainContract.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9297cb083..cd688c852 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 @@ -20,7 +20,8 @@ data class ArchiveMainState( val isShowAddAlbumBottomSheet: Boolean = false, ) { val uploadType: UploadType - get() = if (scannedImageUrl != null || selectedUris.isNotEmpty()) UploadType.SINGLE else UploadType.MULTIPLE + get() = if (scannedImageUrl != null || selectedUris.size == 1) UploadType.SINGLE + else UploadType.MULTIPLE } sealed interface ArchiveMainIntent { From 221d8b40138611635b356a5a35700bf32c9ed18e Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:57:22 +0900 Subject: [PATCH 27/39] =?UTF-8?q?[refactor]=20#64:=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=97=AC=EB=9F=AC=EA=B0=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B3=91=EB=A0=AC=20=EB=B0=8F=20Dispatcher=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/MediaUploadRepositoryImpl.kt | 12 +++++++---- .../usecase/UploadMultiplePhotoUseCase.kt | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt index 2c5551501..381568c0f 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt @@ -11,6 +11,8 @@ import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.model.ContentType import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject class MediaUploadRepositoryImpl @Inject constructor( @@ -71,10 +73,12 @@ class MediaUploadRepositoryImpl @Inject constructor( uri: Uri, contentType: ContentType, ) = runSuspendCatching { - val imageBytes = uri.toByteArray( - context = context, - format = contentType.toCompressFormat(), - ) ?: error("Failed to convert uri to byte array") + val imageBytes = withContext(Dispatchers.Default) { + uri.toByteArray( + context = context, + format = contentType.toCompressFormat(), + ) ?: error("Failed to convert uri to byte array") + } uploadService.uploadImage( presignedUrl = uploadUrl, diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt index ca6093144..d9e8286e2 100644 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt @@ -7,6 +7,9 @@ import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.ContentType import com.neki.android.core.model.Media import com.neki.android.core.model.MediaType +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -34,13 +37,17 @@ class UploadMultiplePhotoUseCase @Inject constructor( ).getOrThrow() // 2. 각 이미지를 Presigned URL로 업로드 - imageUris.forEachIndexed { index, uri -> - val ticket = tickets[index] - mediaUploadRepository.uploadImageFromUri( - uploadUrl = ticket.uploadUrl, - uri = uri, - contentType = contentType, - ).getOrThrow() + coroutineScope { + imageUris.mapIndexed { index, uri -> + async { + val ticket = tickets[index] + mediaUploadRepository.uploadImageFromUri( + uploadUrl = ticket.uploadUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + } + }.awaitAll() } // 3. 사진 등록 (모든 mediaId를 한번에) From 4bd79192d2156cf878f4d7b596d0950a53a26e34 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:58:15 +0900 Subject: [PATCH 28/39] =?UTF-8?q?[refactor]=20#59:=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=ED=8E=98=EC=B9=98=20=EA=B1=B0=EB=A6=AC=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/repository/impl/PhotoRepositoryImpl.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 77aa0597a..4f45f07b1 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 @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.Flow import javax.inject.Inject private const val PAGE_SIZE = 20 -private const val PREFETCH_DISTANCE = 5 +private const val PREFETCH_DISTANCE = 10 class PhotoRepositoryImpl @Inject constructor( private val photoService: PhotoService, @@ -74,7 +74,7 @@ class PhotoRepositoryImpl @Inject constructor( photoService.getFavoriteSummary().data.toModel() } - override fun getPhotosFlow(folderId: Long?): Flow> { + override fun getPhotosFlow(folderId: Long?, sortOrder: SortOrder): Flow> { return Pager( config = PagingConfig( pageSize = PAGE_SIZE, @@ -82,7 +82,7 @@ class PhotoRepositoryImpl @Inject constructor( prefetchDistance = PREFETCH_DISTANCE, enablePlaceholders = false, ), - pagingSourceFactory = { PhotoPagingSource(photoService, folderId) }, + pagingSourceFactory = { PhotoPagingSource(photoService, folderId, sortOrder.name) }, ).flow } From 4a0e1e1ec10b4bab3d73282bf2e13d7730e57d62 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:07:53 +0900 Subject: [PATCH 29/39] =?UTF-8?q?[fix]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8/?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=EA=B0=80=20=EB=8B=AB?= =?UTF-8?q?=ED=9E=88=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사진 삭제 API 호출에 실패했을 경우, 로딩 상태만 `false`로 변경되고 확인 다이얼로그나 바텀시트가 열린 채로 유지되던 문제를 수정합니다. 삭제 실패 시 `isShowDeleteDialog`와 `isShowDeleteBottomSheet` 상태를 `false`로 변경하여 정상적으로 닫히도록 수정했습니다. --- .../impl/album_detail/AlbumDetailViewModel.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 12124d9be..105bcb77f 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 @@ -194,7 +194,12 @@ class AlbumDetailViewModel @AssistedInject constructor( } .onFailure { error -> Timber.e(error) - reduce { copy(isLoading = false) } + reduce { + copy( + isShowDeleteDialog = false, + isLoading = false, + ) + } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) } } @@ -218,7 +223,7 @@ class AlbumDetailViewModel @AssistedInject constructor( copy( selectedPhotos = persistentListOf(), selectMode = SelectMode.DEFAULT, - isShowDeleteDialog = false, + isShowDeleteBottomSheet = false, isLoading = false, ) } @@ -226,7 +231,12 @@ class AlbumDetailViewModel @AssistedInject constructor( } .onFailure { error -> Timber.e(error) - reduce { copy(isLoading = false) } + reduce { + copy( + isShowDeleteBottomSheet = false, + isLoading = false, + ) + } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) } } From e5fb897e5ccd97ddf789209e6dca411e88afc464 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:08:05 +0900 Subject: [PATCH 30/39] =?UTF-8?q?[chore]=20#69:=20=EB=A6=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/archive/impl/album/AllAlbumViewModel.kt | 3 --- .../android/feature/archive/impl/main/ArchiveMainViewModel.kt | 1 - .../neki/android/feature/archive/impl/photo/AllPhotoScreen.kt | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) 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 9d23cd89c..d9235a918 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 @@ -181,11 +181,9 @@ class AllAlbumViewModel @Inject constructor( .onFailure { error -> postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) Timber.e(error) - } reduce { copy(isShowAddAlbumBottomSheet = false) } } - } private fun handleDeleteConfirm( @@ -214,6 +212,5 @@ class AllAlbumViewModel @Inject constructor( ) } } - } } 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 c19cfe7b2..97dcf66e8 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 @@ -250,7 +250,6 @@ class ArchiveMainViewModel @Inject constructor( .onFailure { error -> postSideEffect(ArchiveMainSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) Timber.e(error) - } reduce { copy(isShowAddAlbumBottomSheet = false) } } 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 f760d4404..632753ce5 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 @@ -75,7 +75,7 @@ internal fun AllPhotoRoute( AllPhotoSideEffect.NavigateBack -> navigateBack() AllPhotoSideEffect.ScrollToTop -> coroutineScope.launch { snapshotFlow { pagingItems.loadState.refresh } - .dropWhile { it is LoadState.NotLoading } // 해당 이벤트 도착이, 새로운 pagingItems 조회보다 빠름. + .dropWhile { it is LoadState.NotLoading } // 해당 이벤트 도착이, 새로운 pagingItems 조회보다 빠름. .first { it is LoadState.NotLoading } lazyState.scrollToItem(0) } From 44dc5eaad3b12d4bea6366ac4173913efb483286 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:21:58 +0900 Subject: [PATCH 31/39] =?UTF-8?q?[refactor]=20#69:=20=ED=8F=AC=ED=86=A0?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=83=80=EC=9E=85=EC=9D=84=20ArchiveResul?= =?UTF-8?q?t=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포토카드 상세 화면(`PhotoDetailScreen`)에서 변경 사항이 발생했을 때 상위 화면으로 전달하는 결과 타입을 `Boolean`에서 `ArchiveResult`로 변경합니다. `ArchiveResult`는 `PhotoDeleted`와 `FavoriteChanged` 두 가지 상태를 가지며, 각각 삭제된 사진의 ID와 즐겨찾기 상태가 변경된 사진의 ID 및 상태를 포함합니다. 이를 통해 아카이브 메인, 전체 사진, 앨범 상세 화면 등에서 개별 아이템의 상태를 더 명확하게 갱신할 수 있도록 수정했습니다. --- .../feature/archive/api/ArchiveResult.kt | 9 ++ .../impl/navigation/ArchiveEntryProvider.kt | 99 ++++++++++++------- .../impl/photo_detail/PhotoDetailContract.kt | 3 +- .../impl/photo_detail/PhotoDetailScreen.kt | 2 +- .../impl/photo_detail/PhotoDetailViewModel.kt | 5 +- 5 files changed, 76 insertions(+), 42 deletions(-) create mode 100644 feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt 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 new file mode 100644 index 000000000..dd4bea93d --- /dev/null +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt @@ -0,0 +1,9 @@ +package com.neki.android.feature.archive.api + +sealed interface ArchiveResult { + data class PhotoDeleted(val photoId: List) : ArchiveResult { + constructor(photoId: Long) : this(listOf(photoId)) + } + + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : ArchiveResult +} 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 92549bada..b6bf5b44d 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 @@ -8,6 +8,7 @@ import com.neki.android.core.navigation.Navigator import com.neki.android.core.navigation.result.LocalResultEventBus import com.neki.android.core.navigation.result.ResultEffect import com.neki.android.feature.archive.api.ArchiveNavKey +import com.neki.android.feature.archive.api.ArchiveResult import com.neki.android.feature.archive.api.navigateToAlbumDetail import com.neki.android.feature.archive.api.navigateToAllAlbum import com.neki.android.feature.archive.api.navigateToAllPhoto @@ -19,6 +20,7 @@ import com.neki.android.feature.archive.impl.main.ArchiveMainIntent import com.neki.android.feature.archive.impl.main.ArchiveMainRoute import com.neki.android.feature.archive.impl.main.ArchiveMainViewModel import com.neki.android.feature.archive.impl.photo.AllPhotoRoute +import com.neki.android.feature.archive.impl.photo.AllPhotoViewModel import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailRoute import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailViewModel import com.neki.android.feature.photo_upload.api.QRScanResult @@ -56,8 +58,8 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { } } } - ResultEffect(resultBus) { hasUpdated -> - if (hasUpdated) viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMainScreen) + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMainScreen) } ArchiveMainRoute( @@ -85,50 +87,71 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { } entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { result -> + when (result) { + is ArchiveResult.FavoriteChanged -> TODO(변화된 애만 수정) + is ArchiveResult.PhotoDeleted -> TODO(지워진 데이터만 지우기) + } + } + AllPhotoRoute( + viewModel = viewModel, navigateBack = navigator::goBack, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) - } - entry { - AllAlbumRoute( - navigateBack = navigator::goBack, - navigateToFavoriteAlbum = { id -> - navigator.navigateToAlbumDetail( - id = id, - isFavorite = true, - ) - }, - navigateToAlbumDetail = { id, title -> - navigator.navigateToAlbumDetail( - id = id, - title = title, - isFavorite = false, - ) - }, - ) - } - entry { key -> - AlbumDetailRoute( - viewModel = hiltViewModel( + entry { + AllAlbumRoute( + navigateBack = navigator::goBack, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail( + id = id, + isFavorite = true, + ) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail( + id = id, + title = title, + isFavorite = false, + ) + }, + ) + } + entry { key -> + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel( creationCallback = { factory -> factory.create(key.albumId, key.title, key.isFavorite) }, - ), - navigateBack = navigator::goBack, - navigateToPhotoDetail = navigator::navigateToPhotoDetail, - ) - } + ) - entry { key -> - PhotoDetailRoute( - viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.photo) - }, - ), - navigateBack = navigator::goBack, - ) + ResultEffect(resultBus) { result -> + when (result) { + is ArchiveResult.FavoriteChanged -> TODO(변화된 애만 수정) + is ArchiveResult.PhotoDeleted -> TODO(지워진 데이터만 지우기) + } + } + + AlbumDetailRoute( + viewModel = viewModel, + navigateBack = navigator::goBack, + navigateToPhotoDetail = navigator::navigateToPhotoDetail, + ) + } + + entry { key -> + PhotoDetailRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.photo) + }, + ), + navigateBack = navigator::goBack, + ) + } } } 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 c9a3b9abb..9cdde7136 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,6 +1,7 @@ package com.neki.android.feature.archive.impl.photo_detail import com.neki.android.core.model.Photo +import com.neki.android.feature.archive.api.ArchiveResult data class PhotoDetailState( val isLoading: Boolean = false, @@ -28,7 +29,7 @@ sealed interface PhotoDetailIntent { sealed interface PhotoDetailSideEffect { data object NavigateBack : PhotoDetailSideEffect - data object NotifyArchiveUpdated : PhotoDetailSideEffect + data class NotifyPhotoUpdated(val result: ArchiveResult) : PhotoDetailSideEffect data class ShowToastMessage(val message: String) : PhotoDetailSideEffect data class DownloadImage(val imageUrl: String) : 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 2bd261e4d..f3256d625 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 @@ -39,7 +39,7 @@ internal fun PhotoDetailRoute( viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { PhotoDetailSideEffect.NavigateBack -> navigateBack() - PhotoDetailSideEffect.NotifyArchiveUpdated -> resultEventBus.sendResult(result = true, allowDuplicate = false) + is PhotoDetailSideEffect.NotifyPhotoUpdated -> resultEventBus.sendResult(result = sideEffect.result, allowDuplicate = false) is PhotoDetailSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) is PhotoDetailSideEffect.DownloadImage -> { ImageDownloader.downloadImage(context, sideEffect.imageUrl) 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 c1dd6da20..2ec30eaf7 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,6 +7,7 @@ import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.api.ArchiveResult import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -74,7 +75,7 @@ class PhotoDetailViewModel @AssistedInject constructor( PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce) is PhotoDetailIntent.FavoriteCommitted -> { reduce { copy(committedFavorite = intent.newFavorite) } - postSideEffect(PhotoDetailSideEffect.NotifyArchiveUpdated) + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.FavoriteChanged(photo.id, intent.newFavorite))) } is PhotoDetailIntent.RevertFavorite -> reduce { copy(photo = photo.copy(isFavorite = intent.originalFavorite)) } @@ -109,7 +110,7 @@ class PhotoDetailViewModel @AssistedInject constructor( photoRepository.deletePhoto(state.photo.id) .onSuccess { reduce { copy(isLoading = false) } - postSideEffect(PhotoDetailSideEffect.NotifyArchiveUpdated) + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.PhotoDeleted(photo.id))) postSideEffect(PhotoDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) postSideEffect(PhotoDetailSideEffect.NavigateBack) } From f1b12f3acf9fc32a030fe838ae86805ca2b8104b Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:28:16 +0900 Subject: [PATCH 32/39] =?UTF-8?q?[refactor]=20#69:=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앨범 추가 바텀시트에서 기존 앨범 이름 목록을 가져오는 `remember`에 `uiState.albums`를 키로 추가합니다. 이를 통해 앨범 목록이 변경될 때마다 중복 검사를 위한 이름 목록이 올바르게 갱신되도록 수정합니다. --- .../neki/android/feature/archive/impl/album/AllAlbumScreen.kt | 2 +- .../neki/android/feature/archive/impl/main/ArchiveMainScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 84e58140f..2648fc37a 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 @@ -116,7 +116,7 @@ internal fun AllAlbumScreen( if (uiState.isShowAddAlbumBottomSheet) { val textFieldState = rememberTextFieldState() - val existingAlbumNames = remember { uiState.albums.map { it.title } } + val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } } val errorMessage by remember(textFieldState.text) { derivedStateOf { diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt index 069f9c1a4..8f79a6d99 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt @@ -147,7 +147,7 @@ internal fun ArchiveMainScreen( if (uiState.isShowAddAlbumBottomSheet) { val textFieldState = rememberTextFieldState() - val existingAlbumNames = remember { uiState.albums.map { it.title } } + val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } } val errorMessage by remember(textFieldState.text) { derivedStateOf { From d5f26e4a8e72058d3950cd0e020896146a54a80c Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:31:45 +0900 Subject: [PATCH 33/39] =?UTF-8?q?[refactor]=20#64:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=83=80=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다. - `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다. - `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다. --- .../android/feature/archive/impl/main/ArchiveMainContract.kt | 3 +-- .../android/feature/archive/impl/main/ArchiveMainViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 cd688c852..beac2bf90 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 @@ -20,8 +20,7 @@ data class ArchiveMainState( val isShowAddAlbumBottomSheet: Boolean = false, ) { val uploadType: UploadType - get() = if (scannedImageUrl != null || selectedUris.size == 1) UploadType.SINGLE - else UploadType.MULTIPLE + get() = if (scannedImageUrl != null) UploadType.QR_CODE else UploadType.GALLERY } sealed interface 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 97dcf66e8..e148ac045 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 @@ -175,7 +175,7 @@ class ArchiveMainViewModel @Inject constructor( reduce { copy(isLoading = false) } postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요")) } - if (state.uploadType == UploadType.SINGLE) { + if (state.uploadType == UploadType.QR_CODE) { uploadSingleImage( imageUrl = state.scannedImageUrl ?: return, reduce = reduce, From 36095a6e47ca402a941a1e519ad8ca65da4362de Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:33:14 +0900 Subject: [PATCH 34/39] =?UTF-8?q?[feat]=20#69:=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=97=90=EC=84=9C=20=EC=82=AD=EC=A0=9C/?= =?UTF-8?q?=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AllPhoto/AlbumDetail에 PhotoDeleted, FavoriteChanged Intent 추가 - PagingData에 updatedFavorites 적용하여 즐겨찾기 상태 반영 - ResultEffect로 ArchiveResult 수신 후 ViewModel Intent 전달 --- .../impl/album_detail/AlbumDetailContract.kt | 4 ++++ .../impl/album_detail/AlbumDetailViewModel.kt | 21 +++++++++++++++++-- .../impl/navigation/ArchiveEntryProvider.kt | 18 ++++++++++++---- .../archive/impl/photo/AllPhotoContract.kt | 4 ++++ .../archive/impl/photo/AllPhotoScreen.kt | 2 -- .../archive/impl/photo/AllPhotoViewModel.kt | 21 +++++++++++++++++-- 6 files changed, 60 insertions(+), 10 deletions(-) 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 00f775677..807dc997c 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 @@ -51,6 +51,10 @@ sealed interface AlbumDetailIntent { data class SelectDeleteOption(val option: PhotoDeleteOption) : AlbumDetailIntent data object ClickDeleteBottomSheetCancelButton : AlbumDetailIntent data object ClickDeleteBottomSheetConfirmButton : AlbumDetailIntent + + // Result Intent + data class PhotoDeleted(val photoIds: List) : AlbumDetailIntent + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AlbumDetailIntent } sealed interface AlbumDetailSideEffect { 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 105bcb77f..71afbb124 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import androidx.paging.map import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore @@ -37,6 +38,7 @@ class AlbumDetailViewModel @AssistedInject constructor( } private val deletedPhotoIds = MutableStateFlow>(emptySet()) + private val updatedFavorites = MutableStateFlow>(emptyMap()) private val originalPagingData: Flow> = if (isFavoriteAlbum) { @@ -48,8 +50,15 @@ class AlbumDetailViewModel @AssistedInject constructor( val photoPagingData: Flow> = combine( originalPagingData, deletedPhotoIds, - ) { pagingData, deletedIds -> - pagingData.filter { photo -> photo.id !in deletedIds } + updatedFavorites, + ) { pagingData, deletedIds, favorites -> + pagingData + .filter { photo -> photo.id !in deletedIds } + .map { photo -> + favorites[photo.id]?.let { isFavorite -> + photo.copy(isFavorite = isFavorite) + } ?: photo + } } val store: MviIntentStore = @@ -95,6 +104,14 @@ class AlbumDetailViewModel @AssistedInject constructor( is AlbumDetailIntent.SelectDeleteOption -> reduce { copy(selectedDeleteOption = intent.option) } AlbumDetailIntent.ClickDeleteBottomSheetCancelButton -> reduce { copy(isShowDeleteBottomSheet = false) } AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton -> handleAlbumPhotoDelete(state, reduce, postSideEffect) + + // Result Intent + is AlbumDetailIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AlbumDetailIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } } } 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 b6bf5b44d..31fd5e9c4 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 @@ -14,11 +14,13 @@ 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.AllAlbumRoute +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 import com.neki.android.feature.archive.impl.main.ArchiveMainIntent import com.neki.android.feature.archive.impl.main.ArchiveMainRoute import com.neki.android.feature.archive.impl.main.ArchiveMainViewModel +import com.neki.android.feature.archive.impl.photo.AllPhotoIntent import com.neki.android.feature.archive.impl.photo.AllPhotoRoute import com.neki.android.feature.archive.impl.photo.AllPhotoViewModel import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailRoute @@ -92,8 +94,12 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { ResultEffect(resultBus) { result -> when (result) { - is ArchiveResult.FavoriteChanged -> TODO(변화된 애만 수정) - is ArchiveResult.PhotoDeleted -> TODO(지워진 데이터만 지우기) + is ArchiveResult.FavoriteChanged -> { + viewModel.store.onIntent(AllPhotoIntent.FavoriteChanged(result.photoId, result.isFavorite)) + } + is ArchiveResult.PhotoDeleted -> { + viewModel.store.onIntent(AllPhotoIntent.PhotoDeleted(result.photoId)) + } } } @@ -131,8 +137,12 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { ResultEffect(resultBus) { result -> when (result) { - is ArchiveResult.FavoriteChanged -> TODO(변화된 애만 수정) - is ArchiveResult.PhotoDeleted -> TODO(지워진 데이터만 지우기) + is ArchiveResult.FavoriteChanged -> { + viewModel.store.onIntent(AlbumDetailIntent.FavoriteChanged(result.photoId, result.isFavorite)) + } + is ArchiveResult.PhotoDeleted -> { + viewModel.store.onIntent(AlbumDetailIntent.PhotoDeleted(result.photoId)) + } } } 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 baff84d1e..6471acd82 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 @@ -42,6 +42,10 @@ sealed interface AllPhotoIntent { data object ClickDeleteIcon : AllPhotoIntent data object DismissDeleteDialog : AllPhotoIntent data object ClickDeleteDialogConfirmButton : AllPhotoIntent + + // Result Intent + data class PhotoDeleted(val photoIds: List) : AllPhotoIntent + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AllPhotoIntent } sealed interface 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 632753ce5..f56d9dfb8 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 @@ -54,7 +54,6 @@ import com.neki.android.feature.archive.impl.util.ImageDownloader import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import timber.log.Timber @Composable internal fun AllPhotoRoute( @@ -64,7 +63,6 @@ internal fun AllPhotoRoute( ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() - Timber.d(pagingItems.toString()) val context = LocalContext.current val lazyState = rememberLazyStaggeredGridState() val coroutineScope = rememberCoroutineScope() 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 56eeeb652..fe0189c25 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import androidx.paging.map import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.model.SortOrder @@ -30,6 +31,7 @@ class AllPhotoViewModel @Inject constructor( ) : ViewModel() { private val deletedPhotoIds = MutableStateFlow>(emptySet()) + private val updatedFavorites = MutableStateFlow>(emptyMap()) private val _photoFilter = MutableStateFlow(PhotoFilter.NEWEST) private val _isFavoriteOnly = MutableStateFlow(false) @@ -54,8 +56,15 @@ class AllPhotoViewModel @Inject constructor( val photoPagingData: Flow> = combine( originalPagingData, deletedPhotoIds, - ) { pagingData, deletedIds -> - pagingData.filter { photo -> photo.id !in deletedIds } + updatedFavorites, + ) { pagingData, deletedIds, favorites -> + pagingData + .filter { photo -> photo.id !in deletedIds } + .map { photo -> + favorites[photo.id]?.let { isFavorite -> + photo.copy(isFavorite = isFavorite) + } ?: photo + } } val store: MviIntentStore = @@ -96,6 +105,14 @@ class AllPhotoViewModel @Inject constructor( AllPhotoIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } AllPhotoIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } AllPhotoIntent.ClickDeleteDialogConfirmButton -> deleteSelectedPhotos(state, reduce, postSideEffect) + + // Result Intent + is AllPhotoIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AllPhotoIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } } } From 8c7521ff62871d3d32f1688cb37854346b0e9732 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:34:53 +0900 Subject: [PATCH 35/39] =?UTF-8?q?[fix]=20#69:=20entry=20=EC=A4=91=EC=B2=A9?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/navigation/ArchiveEntryProvider.kt | 99 ++++++++++--------- 1 file changed, 50 insertions(+), 49 deletions(-) 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 31fd5e9c4..676d75e4e 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 @@ -108,60 +108,61 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { navigateBack = navigator::goBack, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) + } - entry { - AllAlbumRoute( - navigateBack = navigator::goBack, - navigateToFavoriteAlbum = { id -> - navigator.navigateToAlbumDetail( - id = id, - isFavorite = true, - ) - }, - navigateToAlbumDetail = { id, title -> - navigator.navigateToAlbumDetail( - id = id, - title = title, - isFavorite = false, - ) - }, - ) - } - entry { key -> - val resultBus = LocalResultEventBus.current - val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.albumId, key.title, key.isFavorite) - }, - ) + entry { + AllAlbumRoute( + navigateBack = navigator::goBack, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail( + id = id, + isFavorite = true, + ) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail( + id = id, + title = title, + isFavorite = false, + ) + }, + ) + } - 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)) - } + entry { key -> + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel( + 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)) } } - - AlbumDetailRoute( - viewModel = viewModel, - navigateBack = navigator::goBack, - navigateToPhotoDetail = navigator::navigateToPhotoDetail, - ) } - entry { key -> - PhotoDetailRoute( - viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.photo) - }, - ), - navigateBack = navigator::goBack, - ) - } + AlbumDetailRoute( + viewModel = viewModel, + navigateBack = navigator::goBack, + navigateToPhotoDetail = navigator::navigateToPhotoDetail, + ) + } + + entry { key -> + PhotoDetailRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.photo) + }, + ), + navigateBack = navigator::goBack, + ) } } From ac6e255a0a30026d7ec38a9d798846de8dd1ade4 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:35:54 +0900 Subject: [PATCH 36/39] =?UTF-8?q?[refactor]=20#64:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=83=80=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다. - `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다. - `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다. --- .../src/main/java/com/neki/android/core/model/UploadType.kt | 4 ++-- .../feature/photo_upload/impl/album/UploadAlbumContract.kt | 2 +- .../feature/photo_upload/impl/album/UploadAlbumViewModel.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/model/src/main/java/com/neki/android/core/model/UploadType.kt b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt index 94c165958..c6a5b8264 100644 --- a/core/model/src/main/java/com/neki/android/core/model/UploadType.kt +++ b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt @@ -1,6 +1,6 @@ package com.neki.android.core.model enum class UploadType { - SINGLE, - MULTIPLE, + QR_CODE, + GALLERY, } diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt index a193546dc..923202208 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt @@ -18,7 +18,7 @@ data class UploadAlbumState( val count: Int get() = if (imageUrl == null) selectedUris.size else 1 val uploadType: UploadType - get() = if (imageUrl == null) UploadType.MULTIPLE else UploadType.SINGLE + get() = if (imageUrl == null) UploadType.GALLERY else UploadType.QR_CODE } sealed interface UploadAlbumIntent { diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt index f48a9c92c..b12957bd1 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt @@ -122,7 +122,7 @@ class UploadAlbumViewModel @AssistedInject constructor( postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) } - if (state.uploadType == UploadType.SINGLE) { + if (state.uploadType == UploadType.QR_CODE) { uploadSingleImage( imageUrl = state.imageUrl ?: return, albumId = firstAlbum.id, From 1f522033c63065bc4777b216673942e1220ca2d5 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:03:10 +0900 Subject: [PATCH 37/39] =?UTF-8?q?[build]=20#59,64,69:=20detekt=20=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/di/RepositoryModule.kt | 4 +- .../impl/navigation/ArchiveEntryProvider.kt | 47 ++++++++----------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index 3d92b8ed3..dfb98f460 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -67,7 +67,9 @@ internal interface RepositoryModule { fun bindFolderRepositoryImpl( folderRepositoryImpl: FolderRepositoryImpl, ): FolderRepository - + + @Binds + @Singleton fun bindMapRepositoryImpl( mapRepositoryImpl: MapRepositoryImpl, ): MapRepository 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 676d75e4e..0220f3096 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 @@ -41,11 +41,15 @@ object ArchiveEntryProviderModule { @IntoSet @Provides fun provideArchiveEntryBuilder(navigator: Navigator): EntryProviderInstaller = { - archiveEntry(navigator) + archiveMainEntry(navigator) + allPhotoEntry(navigator) + allAlbumEntry(navigator) + albumDetailEntry(navigator) + photoDetailEntry(navigator) } } -private fun EntryProviderScope.archiveEntry(navigator: Navigator) { +private fun EntryProviderScope.archiveMainEntry(navigator: Navigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() @@ -54,7 +58,6 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { is QRScanResult.QRCodeScanned -> { viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(result.imageUrl)) } - QRScanResult.OpenGallery -> { viewModel.store.onIntent(ArchiveMainIntent.ClickGalleryUploadRow) } @@ -71,23 +74,18 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { navigateToUploadAlbumWithQRScan = navigator::navigateToUploadAlbum, navigateToAllAlbum = navigator::navigateToAllAlbum, navigateToFavoriteAlbum = { id -> - navigator.navigateToAlbumDetail( - id = id, - isFavorite = true, - ) + navigator.navigateToAlbumDetail(id = id, isFavorite = true) }, navigateToAlbumDetail = { id, title -> - navigator.navigateToAlbumDetail( - id = id, - title = title, - isFavorite = false, - ) + navigator.navigateToAlbumDetail(id = id, title = title, isFavorite = false) }, navigateToAllPhoto = navigator::navigateToAllPhoto, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } +} +private fun EntryProviderScope.allPhotoEntry(navigator: Navigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() @@ -109,32 +107,27 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } +} +private fun EntryProviderScope.allAlbumEntry(navigator: Navigator) { entry { AllAlbumRoute( navigateBack = navigator::goBack, navigateToFavoriteAlbum = { id -> - navigator.navigateToAlbumDetail( - id = id, - isFavorite = true, - ) + navigator.navigateToAlbumDetail(id = id, isFavorite = true) }, navigateToAlbumDetail = { id, title -> - navigator.navigateToAlbumDetail( - id = id, - title = title, - isFavorite = false, - ) + navigator.navigateToAlbumDetail(id = id, title = title, isFavorite = false) }, ) } +} +private fun EntryProviderScope.albumDetailEntry(navigator: Navigator) { entry { key -> val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.albumId, key.title, key.isFavorite) - }, + creationCallback = { factory -> factory.create(key.albumId, key.title, key.isFavorite) }, ) ResultEffect(resultBus) { result -> @@ -154,13 +147,13 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } +} +private fun EntryProviderScope.photoDetailEntry(navigator: Navigator) { entry { key -> PhotoDetailRoute( viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.photo) - }, + creationCallback = { factory -> factory.create(key.photo) }, ), navigateBack = navigator::goBack, ) From 7bdf2b66351d3dfb33f8ae4c8ce052135eb17e5d Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:07:03 +0900 Subject: [PATCH 38/39] =?UTF-8?q?[refactor]=20=EC=95=84=EC=B9=B4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20EntryProvider=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ArchiveEntryProvider` 내에 개별적으로 존재하던 `archiveMainEntry`, `allPhotoEntry`, `allAlbumEntry`, `albumDetailEntry`, `photoDetailEntry` 함수들을 단일 `archiveEntry` 함수로 통합합니다. --- .../impl/navigation/ArchiveEntryProvider.kt | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) 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 0220f3096..b0a30f01c 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 @@ -41,15 +41,11 @@ object ArchiveEntryProviderModule { @IntoSet @Provides fun provideArchiveEntryBuilder(navigator: Navigator): EntryProviderInstaller = { - archiveMainEntry(navigator) - allPhotoEntry(navigator) - allAlbumEntry(navigator) - albumDetailEntry(navigator) - photoDetailEntry(navigator) + archiveEntry(navigator) } } -private fun EntryProviderScope.archiveMainEntry(navigator: Navigator) { +private fun EntryProviderScope.archiveEntry(navigator: Navigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() @@ -58,6 +54,7 @@ private fun EntryProviderScope.archiveMainEntry(navigator: Navigator) { is QRScanResult.QRCodeScanned -> { viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(result.imageUrl)) } + QRScanResult.OpenGallery -> { viewModel.store.onIntent(ArchiveMainIntent.ClickGalleryUploadRow) } @@ -83,9 +80,7 @@ private fun EntryProviderScope.archiveMainEntry(navigator: Navigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } -} -private fun EntryProviderScope.allPhotoEntry(navigator: Navigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() @@ -95,6 +90,7 @@ private fun EntryProviderScope.allPhotoEntry(navigator: Navigator) { is ArchiveResult.FavoriteChanged -> { viewModel.store.onIntent(AllPhotoIntent.FavoriteChanged(result.photoId, result.isFavorite)) } + is ArchiveResult.PhotoDeleted -> { viewModel.store.onIntent(AllPhotoIntent.PhotoDeleted(result.photoId)) } @@ -107,9 +103,7 @@ private fun EntryProviderScope.allPhotoEntry(navigator: Navigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } -} -private fun EntryProviderScope.allAlbumEntry(navigator: Navigator) { entry { AllAlbumRoute( navigateBack = navigator::goBack, @@ -121,9 +115,7 @@ private fun EntryProviderScope.allAlbumEntry(navigator: Navigator) { }, ) } -} -private fun EntryProviderScope.albumDetailEntry(navigator: Navigator) { entry { key -> val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel( @@ -135,6 +127,7 @@ private fun EntryProviderScope.albumDetailEntry(navigator: Navigator) { is ArchiveResult.FavoriteChanged -> { viewModel.store.onIntent(AlbumDetailIntent.FavoriteChanged(result.photoId, result.isFavorite)) } + is ArchiveResult.PhotoDeleted -> { viewModel.store.onIntent(AlbumDetailIntent.PhotoDeleted(result.photoId)) } @@ -147,9 +140,7 @@ private fun EntryProviderScope.albumDetailEntry(navigator: Navigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) } -} -private fun EntryProviderScope.photoDetailEntry(navigator: Navigator) { entry { key -> PhotoDetailRoute( viewModel = hiltViewModel( From 75ed9d5b0d59907940ac912eff35ddde32bb9c0d Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:06:22 +0900 Subject: [PATCH 39/39] =?UTF-8?q?[feat]=20#70:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20deletePhotos=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=A8=EB=B2=94=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EC=82=AD=EC=A0=9C=20=EC=98=B5=EC=85=98=EB=B3=84=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/folders에 deletePhotos 쿼리 파라미터 추가 - DELETE /api/folders/{folderId}/photos 폴더에서 사진 제거 API 추가 - AlbumDeleteOption에 따라 사진 포함/미포함 삭제 분기 - PhotoDeleteOption에 따라 앨범에서만 제거/완전 삭제 분기 --- .../core/dataapi/repository/FolderRepository.kt | 3 ++- .../core/data/remote/api/FolderService.kt | 17 +++++++++++++++-- .../repository/impl/FolderRepositoryImpl.kt | 11 ++++++++++- .../archive/impl/album/AllAlbumViewModel.kt | 4 ++-- .../impl/album_detail/AlbumDetailViewModel.kt | 9 ++++++++- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt index ff33722d8..b8f1e1096 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -5,5 +5,6 @@ import com.neki.android.core.model.AlbumPreview interface FolderRepository { suspend fun getFolders(): Result> suspend fun createFolder(name: String): Result - suspend fun deleteFolder(id: List): Result + suspend fun deleteFolder(id: List, deletePhotos: Boolean): Result + suspend fun removePhotosFromFolder(folderId: Long, photoIds: List): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt index cc776eaa8..424030027 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -2,13 +2,16 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.request.CreateFolderRequest import com.neki.android.core.data.remote.model.request.DeleteFolderRequest +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest 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.CreateFolderResponse import com.neki.android.core.data.remote.model.response.FolderResponse import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.get +import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import javax.inject.Inject @@ -27,7 +30,17 @@ class FolderService @Inject constructor( } // 폴더 삭제 - suspend fun deleteFolder(requestBody: DeleteFolderRequest): BasicNullableResponse { - return client.post("/api/folders") { setBody(requestBody) }.body() + suspend fun deleteFolder(requestBody: DeleteFolderRequest, deletePhotos: Boolean): BasicNullableResponse { + return client.delete("/api/folders") { + setBody(requestBody) + parameter("deletePhotos", deletePhotos) + }.body() + } + + // 폴더에서 사진 제거 (사진 자체는 삭제되지 않음) + suspend fun removePhotosFromFolder(folderId: Long, requestBody: DeletePhotoRequest): BasicNullableResponse { + return client.delete("/api/folders/$folderId/photos") { + setBody(requestBody) + }.body() } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt index ab2504fbd..7404f274e 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.neki.android.core.data.repository.impl import com.neki.android.core.data.remote.api.FolderService import com.neki.android.core.data.remote.model.request.CreateFolderRequest import com.neki.android.core.data.remote.model.request.DeleteFolderRequest +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.model.AlbumPreview @@ -21,9 +22,17 @@ class FolderRepositoryImpl @Inject constructor( ).data } - override suspend fun deleteFolder(id: List): Result = runSuspendCatching { + override suspend fun deleteFolder(id: List, deletePhotos: Boolean): Result = runSuspendCatching { folderService.deleteFolder( requestBody = DeleteFolderRequest(folderIds = id), + deletePhotos = deletePhotos, + ).data + } + + override suspend fun removePhotosFromFolder(folderId: Long, photoIds: List): Result = runSuspendCatching { + folderService.removePhotosFromFolder( + folderId = folderId, + requestBody = DeletePhotoRequest(photoIds = photoIds), ).data } } 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 d9235a918..8b803102b 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 @@ -191,11 +191,11 @@ class AllAlbumViewModel @Inject constructor( reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { - // TODO: 삭제 타입에 따라 핸들링 viewModelScope.launch { val selectedAlbumIds = state.selectedAlbums.map { it.id } + val deletePhotos = state.selectedDeleteOption == AlbumDeleteOption.DELETE_WITH_PHOTOS - folderRepository.deleteFolder(selectedAlbumIds) + folderRepository.deleteFolder(selectedAlbumIds, deletePhotos) .onSuccess { fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 삭제했어요")) 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 71afbb124..a9150c55b 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 @@ -6,6 +6,7 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map +import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore @@ -30,6 +31,7 @@ class AlbumDetailViewModel @AssistedInject constructor( @Assisted private val title: String, @Assisted private val isFavoriteAlbum: Boolean, private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, ) : ViewModel() { @AssistedFactory @@ -232,7 +234,12 @@ class AlbumDetailViewModel @AssistedInject constructor( viewModelScope.launch { reduce { copy(isLoading = true) } - photoRepository.deletePhoto(photoIds = selectedPhotoIds) + val result = when (state.selectedDeleteOption) { + PhotoDeleteOption.REMOVE_FROM_ALBUM -> folderRepository.removePhotosFromFolder(id, selectedPhotoIds) + PhotoDeleteOption.REMOVE_FROM_ALL -> photoRepository.deletePhoto(photoIds = selectedPhotoIds) + } + + result .onSuccess { Timber.d("삭제 성공") deletedPhotoIds.update { it + selectedPhotoIds.toSet() }