diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index 2cbcec861..c3d918b7f 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -1,9 +1,14 @@ plugins { - alias(libs.plugins.neki.kotlin.library) + alias(libs.plugins.neki.android.library) +} + +android { + namespace = "com.neki.android.core.dataapi" } 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-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..b8f1e1096 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -0,0 +1,10 @@ +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 + suspend fun deleteFolder(id: List, deletePhotos: Boolean): Result + suspend fun removePhotosFromFolder(folderId: Long, photoIds: List): Result +} 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-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..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 @@ -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,13 @@ interface PhotoRepository { ): Result> suspend fun getFavoriteSummary(): Result + + fun getPhotosFlow( + folderId: Long? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + + fun getFavoritePhotosFlow( + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> } 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..a6b42fa1a --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt @@ -0,0 +1,42 @@ +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?, + private val sortOrder: String, +) : 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, + sortOrder = sortOrder, + ) + 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/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..424030027 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -0,0 +1,46 @@ +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 + +class FolderService @Inject constructor( + private val client: HttpClient, +) { + // 폴더 목록 조회 + suspend fun getFolders(): BasicResponse { + return client.get("/api/folders").body() + } + + // 폴더 생성 + suspend fun createFolder(requestBody: CreateFolderRequest): 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/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/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/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/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..e8a5d88e8 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt @@ -0,0 +1,10 @@ +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/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 33c84eb8f..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 @@ -4,10 +4,12 @@ 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.MapRepositoryImpl 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 @@ -60,6 +62,12 @@ internal interface RepositoryModule { photoRepositoryImpl: PhotoRepositoryImpl, ): PhotoRepository + @Binds + @Singleton + fun bindFolderRepositoryImpl( + folderRepositoryImpl: FolderRepositoryImpl, + ): FolderRepository + @Binds @Singleton fun bindMapRepositoryImpl( 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..7404f274e --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -0,0 +1,38 @@ +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 +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 + } + + 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/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..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 @@ -1,16 +1,43 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject class MediaUploadRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, 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, @@ -40,4 +67,44 @@ class MediaUploadRepositoryImpl @Inject constructor( contentType = contentType.label, ) } + + override suspend fun uploadImageFromUri( + uploadUrl: String, + uri: Uri, + contentType: ContentType, + ) = runSuspendCatching { + val imageBytes = withContext(Dispatchers.Default) { + 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 + } } 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..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 @@ -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 = 10 + class PhotoRepositoryImpl @Inject constructor( private val photoService: PhotoService, ) : PhotoRepository { @@ -64,4 +73,28 @@ class PhotoRepositoryImpl @Inject constructor( override suspend fun getFavoriteSummary(): Result = runSuspendCatching { photoService.getFavoriteSummary().data.toModel() } + + override fun getPhotosFlow(folderId: Long?, sortOrder: SortOrder): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { PhotoPagingSource(photoService, folderId, sortOrder.name) }, + ).flow + } + + override fun getFavoritePhotosFlow(sortOrder: SortOrder): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { FavoritePhotoPagingSource(photoService, sortOrder) }, + ).flow + } } 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..d9e8286e2 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt @@ -0,0 +1,77 @@ +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 kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +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로 업로드 + coroutineScope { + imageUris.mapIndexed { index, uri -> + async { + val ticket = tickets[index] + mediaUploadRepository.uploadImageFromUri( + uploadUrl = ticket.uploadUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + } + }.awaitAll() + } + + // 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" + } +} 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() 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..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 { - QR_SCAN, + QR_CODE, GALLERY, } 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/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/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/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/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..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 @@ -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, @@ -40,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 @@ -55,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 50161c447..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 @@ -20,11 +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.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.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 @@ -39,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 @@ -49,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) } @@ -109,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)) }, ) } } @@ -117,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 { @@ -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..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 @@ -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( @@ -56,7 +65,7 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { 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) } @@ -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( @@ -165,22 +141,22 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { } 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 { @@ -196,14 +172,18 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, postSideEffect: (AllAlbumSideEffect) -> Unit, ) { - // TODO: Add album to repository - reduce { - copy( - isShowAddAlbumBottomSheet = false, - albums = (albums + Album(id = albums.size.toLong(), title = albumName)).toImmutableList(), - ) + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { + fetchFolders(reduce) + postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + } + .onFailure { error -> + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) + Timber.e(error) + } + reduce { copy(isShowAddAlbumBottomSheet = false) } } - postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } private fun handleDeleteConfirm( @@ -211,15 +191,26 @@ class AllAlbumViewModel @Inject constructor() : ViewModel() { 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, - ) + viewModelScope.launch { + val selectedAlbumIds = state.selectedAlbums.map { it.id } + val deletePhotos = state.selectedDeleteOption == AlbumDeleteOption.DELETE_WITH_PHOTOS + + folderRepository.deleteFolder(selectedAlbumIds, deletePhotos) + .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("앨범을 삭제했어요")) } } 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..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 @@ -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, @@ -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/AlbumDetailScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt index edb0d497b..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 @@ -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 @@ -33,11 +38,12 @@ 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 -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -47,6 +53,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 +79,7 @@ internal fun AlbumDetailRoute( AlbumDetailScreen( uiState = uiState, + pagingItems = pagingItems, onIntent = viewModel.store::onIntent, ) } @@ -79,11 +87,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 +106,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) }, @@ -112,24 +124,44 @@ 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), ) { 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 +174,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 +192,6 @@ internal fun AlbumDetailScreen( ) } - // Delete BottomSheet for Regular Album if (uiState.isShowDeleteBottomSheet) { DoubleButtonOptionBottomSheet( title = "사진을 삭제하시겠어요?", @@ -213,91 +243,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 b72a43fe3..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 @@ -2,6 +2,11 @@ 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 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 @@ -13,39 +18,58 @@ 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 @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, + private val folderRepository: FolderRepository, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(id: Long, isFavoriteAlbum: Boolean): AlbumDetailViewModel + fun create(id: Long, title: String, 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"), - ) + private val deletedPhotoIds = MutableStateFlow>(emptySet()) + private val updatedFavorites = MutableStateFlow>(emptyMap()) + + private val originalPagingData: Flow> = + if (isFavoriteAlbum) { + photoRepository.getFavoritePhotosFlow() + } else { + photoRepository.getPhotosFlow(id) + }.cachedIn(viewModelScope) + + val photoPagingData: Flow> = combine( + originalPagingData, + deletedPhotoIds, + 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 = mviIntentStore( initialState = AlbumDetailState( + title = title, isFavoriteAlbum = isFavoriteAlbum, ), onIntent = ::onIntent, - initialFetchData = { store.onIntent(AlbumDetailIntent.EnterAlbumDetailScreen) }, ) private fun onIntent( @@ -55,10 +79,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) @@ -71,51 +93,27 @@ 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) } - } - } + AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton -> handleAlbumPhotoDelete(state, reduce, postSideEffect) - private fun fetchAlbumData( - id: Long, - reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, - ) { - // TODO: Fetch album from repository - reduce { - copy( - photoList = dummyPhotos, - ) + // Result Intent + is AlbumDetailIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AlbumDetailIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } } } @@ -194,50 +192,77 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + viewModelScope.launch { reduce { copy(isLoading = true) } - val selectedPhotoIds = state.selectedPhotos.map { it.id } + 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( + isShowDeleteDialog = false, + isLoading = false, + ) + } postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) } - reduce { copy(isLoading = false) } } } - private fun handleAlbumDelete( + private fun handleAlbumPhotoDelete( state: AlbumDetailState, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { - reduce { - copy( - photoList = photoList.filter { photo -> - selectedPhotos.none { it.id == photo.id } - }.toImmutableList(), - selectedPhotos = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteBottomSheet = false, - ) - } + val selectedPhotoIds = state.selectedPhotos.map { it.id } + + viewModelScope.launch { + reduce { copy(isLoading = true) } - val message = when (state.selectedDeleteOption) { - PhotoDeleteOption.REMOVE_FROM_ALBUM -> "앨범에서 사진을 제거했어요" - PhotoDeleteOption.REMOVE_FROM_ALL -> "사진을 삭제했어요" + 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() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteBottomSheet = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { + copy( + isShowDeleteBottomSheet = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } } - postSideEffect(AlbumDetailSideEffect.ShowToastMessage(message)) } } 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/ArchiveMainContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt index 9a332302e..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,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) UploadType.QR_CODE else UploadType.GALLERY } sealed interface ArchiveMainIntent { @@ -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..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 @@ -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 @@ -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)) }, ) @@ -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 { @@ -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, @@ -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/main/ArchiveMainViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt index 6b1c13d4e..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 @@ -3,8 +3,9 @@ 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.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.UploadType import com.neki.android.core.ui.MviIntentStore @@ -24,7 +25,9 @@ private const val DEFAULT_PHOTOS_SIZE = 20 @HiltViewModel class ArchiveMainViewModel @Inject constructor( private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, ) : ViewModel() { val store: MviIntentStore = @@ -103,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) @@ -124,6 +127,7 @@ class ArchiveMainViewModel @Inject constructor( awaitAll( async { fetchFavoriteSummary(reduce) }, async { fetchPhotos(reduce) }, + async { fetchFolders(reduce) }, ) } finally { reduce { copy(isLoading = false) } @@ -151,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, @@ -161,7 +175,7 @@ class ArchiveMainViewModel @Inject constructor( reduce { copy(isLoading = false) } postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요")) } - if (state.uploadType == UploadType.QR_SCAN) { + if (state.uploadType == UploadType.QR_CODE) { uploadSingleImage( imageUrl = state.scannedImageUrl ?: return, reduce = reduce, @@ -186,10 +200,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 +220,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( @@ -216,8 +241,17 @@ class ArchiveMainViewModel @Inject constructor( reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, postSideEffect: (ArchiveMainSideEffect) -> Unit, ) { - // TODO: Add album to repository - reduce { copy(isShowAddAlbumBottomSheet = false) } - postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { + fetchFolders(reduce) + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + } + .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/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..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 @@ -8,17 +8,21 @@ 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 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 import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailViewModel import com.neki.android.feature.photo_upload.api.QRScanResult @@ -56,8 +60,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( @@ -66,15 +70,35 @@ 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, ) } entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { result -> + when (result) { + is ArchiveResult.FavoriteChanged -> { + viewModel.store.onIntent(AllPhotoIntent.FavoriteChanged(result.photoId, result.isFavorite)) + } + + is ArchiveResult.PhotoDeleted -> { + viewModel.store.onIntent(AllPhotoIntent.PhotoDeleted(result.photoId)) + } + } + } + AllPhotoRoute( + viewModel = viewModel, navigateBack = navigator::goBack, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) @@ -83,17 +107,35 @@ 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 -> + 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 = hiltViewModel( - creationCallback = { factory -> - factory.create(key.albumId, key.isFavorite) - }, - ), + viewModel = viewModel, navigateBack = navigator::goBack, navigateToPhotoDetail = navigator::navigateToPhotoDetail, ) @@ -102,9 +144,7 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { entry { key -> PhotoDetailRoute( viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.photo) - }, + 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/AllPhotoContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt index e2f4655ab..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 @@ -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(), @@ -44,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 6aca2d298..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 @@ -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,8 @@ 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 import androidx.compose.ui.platform.LocalContext @@ -27,22 +30,29 @@ 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 -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 -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.flow.dropWhile +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Composable @@ -52,6 +62,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() @@ -60,7 +71,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) @@ -80,6 +97,7 @@ internal fun AllPhotoRoute( AllPhotoScreen( uiState = uiState, + pagingItems = pagingItems, lazyState = lazyState, onIntent = viewModel.store::onIntent, ) @@ -87,7 +105,8 @@ internal fun AllPhotoRoute( @Composable internal fun AllPhotoScreen( - uiState: AllPhotoState = AllPhotoState(), + uiState: AllPhotoState, + pagingItems: LazyPagingItems, lazyState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), onIntent: (AllPhotoIntent) -> Unit = {}, ) { @@ -102,6 +121,8 @@ internal fun AllPhotoScreen( } } + val isRefreshing by remember { derivedStateOf { pagingItems.loadState.refresh is LoadState.Loading } } + BackHandler(enabled = true) { onIntent(AllPhotoIntent.OnBackPressed) } @@ -128,26 +149,44 @@ 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), ) { 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 +215,10 @@ internal fun AllPhotoScreen( ) } + if (isRefreshing || uiState.isLoading) { + LoadingDialog() + } + if (uiState.isShowDeleteDialog) { DeletePhotoDialog( onDismissRequest = { onIntent(AllPhotoIntent.DismissDeleteDialog) }, @@ -188,47 +231,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..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 @@ -1,23 +1,76 @@ 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 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 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 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 updatedFavorites = MutableStateFlow>(emptyMap()) + 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, + deletedPhotoIds, + 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 = mviIntentStore( initialState = AllPhotoState(), onIntent = ::onIntent, - initialFetchData = { store.onIntent(AllPhotoIntent.EnterAllPhotoScreen) }, ) private fun onIntent( @@ -27,7 +80,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) @@ -43,7 +96,7 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { // 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 @@ -52,46 +105,14 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { AllPhotoIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } AllPhotoIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } AllPhotoIntent.ClickDeleteDialogConfirmButton -> deleteSelectedPhotos(state, reduce, postSideEffect) - } - } - - 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, - ) + // Result Intent + is AllPhotoIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AllPhotoIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } } } @@ -114,18 +135,12 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { private fun handleFavoriteFilter( state: AllPhotoState, reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> 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(), - ) - } + val newValue = !state.isFavoriteChipSelected + _isFavoriteOnly.value = newValue + reduce { copy(isFavoriteChipSelected = newValue) } + postSideEffect(AllPhotoSideEffect.ScrollToTop) } private fun handleFilterRow( @@ -133,15 +148,11 @@ class AllPhotoViewModel @Inject constructor() : ViewModel() { reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, postSideEffect: (AllPhotoSideEffect) -> Unit, ) { + _photoFilter.value = filter 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 +204,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("사진을 삭제했어요")) } } 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) } 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..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 @@ -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,14 +11,14 @@ data class UploadAlbumState( val isLoading: Boolean = false, val imageUrl: String? = null, val selectedUris: ImmutableList = persistentListOf(), - val favoriteAlbum: Album = Album(), - val albums: ImmutableList = persistentListOf(), - val selectedAlbumIds: PersistentList = persistentListOf(), + val favoriteAlbum: AlbumPreview = AlbumPreview(), + val albums: ImmutableList = persistentListOf(), + val selectedAlbums: PersistentList = persistentListOf(), ) { 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.GALLERY else UploadType.QR_CODE } sealed interface UploadAlbumIntent { @@ -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 5eaa69d97..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 @@ -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 @@ -32,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 @@ -41,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) } } @@ -82,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)) }, ) } } @@ -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,19 @@ 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), + ), + 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 d96733049..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 @@ -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.common.util.urlToByteArray +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 @@ -25,6 +27,9 @@ class UploadAlbumViewModel @AssistedInject constructor( @Assisted private val imageUrl: String?, @Assisted private val uriStrings: List, private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, ) : ViewModel() { @AssistedFactory @@ -55,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(), ) } @@ -67,39 +72,49 @@ 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, 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) @@ -107,10 +122,10 @@ class UploadAlbumViewModel @AssistedInject constructor( postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) } - if (state.uploadType == UploadType.QR_SCAN) { + if (state.uploadType == UploadType.QR_CODE) { uploadSingleImage( imageUrl = state.imageUrl ?: return, - albumId = firstAlbumId, + albumId = firstAlbum.id, reduce = reduce, onSuccessAction = onSuccessAction, onFailureAction = onFailureAction, @@ -118,7 +133,8 @@ class UploadAlbumViewModel @AssistedInject constructor( } else { uploadMultipleImages( imageUris = state.selectedUris, - albumId = firstAlbumId, + albumId = firstAlbum.id, + reduce = reduce, onSuccessAction = onSuccessAction, onFailureAction = onFailureAction, ) @@ -134,10 +150,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 +166,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) + } + } } } 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) }, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91a5e5587..b0b1b5b35 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,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" } @@ -99,6 +100,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" }