diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index f389ae002..b3377e80e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.neki.android.library) alias(libs.plugins.neki.android.library.compose) + alias(libs.plugins.neki.hilt) } android { diff --git a/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt b/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt new file mode 100644 index 000000000..27a93f69e --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.common.coroutine.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopeModule { + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} 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 db04af32d..f2352224e 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 @@ -5,10 +5,11 @@ import com.neki.android.core.model.MediaUploadTicket interface MediaUploadRepository { suspend fun getUploadTicket( + uploadCount: Int = 1, fileName: String, contentType: String, mediaType: String, - ): Result + ): Result> suspend fun uploadImage( uploadUrl: String, 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 0ae7f8e94..8a38ee35f 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,6 +1,8 @@ package com.neki.android.core.dataapi.repository +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder interface PhotoRepository { suspend fun getPhotos( @@ -10,9 +12,20 @@ interface PhotoRepository { ): Result> suspend fun registerPhoto( - mediaId: Long, + mediaIds: List, folderId: Long? = null, - ): Result + ): Result suspend fun deletePhoto(photoId: Long): Result + suspend fun deletePhoto(photoIds: List): Result + + suspend fun updateFavorite(photoId: Long, favorite: Boolean): Result + + suspend fun getFavoritePhotos( + page: Int = 0, + size: Int = 20, + sortOrder: SortOrder = SortOrder.DESC, + ): Result> + + suspend fun getFavoriteSummary(): Result } 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 0f1de3ce0..2e36686a7 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 @@ -1,8 +1,12 @@ package com.neki.android.core.data.remote.api +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest import com.neki.android.core.data.remote.model.request.RegisterPhotoRequest -import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.request.UpdateFavoriteRequest 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.FavoritePhotoResponse +import com.neki.android.core.data.remote.model.response.FavoriteSummaryResponse import com.neki.android.core.data.remote.model.response.PhotoResponse import com.neki.android.core.data.remote.model.response.RegisterPhotoResponse import io.ktor.client.HttpClient @@ -10,6 +14,7 @@ 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.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import javax.inject.Inject @@ -31,12 +36,37 @@ class PhotoService @Inject constructor( } // 사진 등록 - suspend fun registerPhoto(requestBody: RegisterPhotoRequest): BasicResponse { + suspend fun registerPhoto(requestBody: RegisterPhotoRequest): BasicNullableResponse { return client.post("/api/photos") { setBody(requestBody) }.body() } // 사진 삭제 - suspend fun deletePhoto(photoId: Long): BasicNullableResponse { - return client.delete("/api/photos/$photoId").body() + suspend fun deletePhoto(requestBody: DeletePhotoRequest): BasicNullableResponse { + return client.delete("/api/photos") { setBody(requestBody) }.body() + } + + // 즐겨찾기 업데이트 + suspend fun updateFavorite(photoId: Long, favorite: Boolean): BasicNullableResponse { + return client.patch("/api/photos/$photoId/favorite") { + setBody(UpdateFavoriteRequest(favorite)) + }.body() + } + + // 즐겨찾기 사진 조회 + suspend fun getFavoritePhotos( + page: Int = 0, + size: Int = 20, + sortOrder: String, + ): BasicResponse { + return client.get("/api/photos/favorite") { + parameter("page", page) + parameter("size", size) + parameter("sortOrder", sortOrder) + }.body() + } + + // 즐겨찾기 요약 조회 + suspend fun getFavoriteSummary(): BasicResponse { + return client.get("/api/photos/favorite/summary").body() } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt index 9e3a29232..7eeb272ce 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt @@ -2,7 +2,7 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.request.MediaUploadTicketRequest import com.neki.android.core.data.remote.model.response.BasicResponse -import com.neki.android.core.data.remote.model.response.MediaUploadTicketResponse +import com.neki.android.core.data.remote.model.response.MediaUploadTicketDataResponse import com.neki.android.core.data.remote.qualifier.UploadHttpClient import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -18,7 +18,7 @@ class UploadService @Inject constructor( @UploadHttpClient private val uploadClient: HttpClient, ) { // Media Upload Ticket 받기 - suspend fun getUploadTicket(requestBody: MediaUploadTicketRequest): BasicResponse { + suspend fun getUploadTicket(requestBody: MediaUploadTicketRequest): BasicResponse { return client.post("/api/media/upload") { setBody(requestBody) }.body() } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.kt new file mode 100644 index 000000000..b2cefa21c --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.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 DeletePhotoRequest( + @SerialName("photoIds") val photoIds: List, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt index 365d05a92..0bdda69e3 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt @@ -5,7 +5,12 @@ import kotlinx.serialization.Serializable @Serializable data class MediaUploadTicketRequest( - @SerialName("contentType") val contentType: String, - @SerialName("filename") val filename: String = "", - @SerialName("mediaType") val mediaType: String, -) + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("contentType") val contentType: String, + @SerialName("filename") val filename: String = "", + @SerialName("mediaType") val mediaType: String, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt index 336ec81f6..608ab9920 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt @@ -5,7 +5,12 @@ import kotlinx.serialization.Serializable @Serializable data class RegisterPhotoRequest( - @SerialName("folderId") val folderId: Long?, - @SerialName("mediaId") val mediaId: Long, - @SerialName("memo") val memo: String = "", -) + @SerialName("folderId") val folderId: Long? = null, + @SerialName("uploads") val uploads: List, +) { + @Serializable + data class Upload( + @SerialName("mediaId") val mediaId: Long, + @SerialName("memo") val memo: String? = null, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.kt new file mode 100644 index 000000000..56b456cf6 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.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 UpdateFavoriteRequest( + @SerialName("favorite") val favorite: Boolean, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt new file mode 100644 index 000000000..ba4ebf2f0 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt @@ -0,0 +1,31 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.common.util.toFormattedDate +import com.neki.android.core.model.Photo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FavoritePhotoResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + @SerialName("favorite") val favorite: Boolean, + @SerialName("folderId") val folderId: Long?, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("photoId") val photoId: Long, + ) { + internal fun toModel() = Photo( + id = photoId, + imageUrl = imageUrl, + isFavorite = favorite, + date = createdAt.toFormattedDate(), + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt new file mode 100644 index 000000000..66aa27c60 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt @@ -0,0 +1,18 @@ +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 FavoriteSummaryResponse( + @SerialName("latestImageUrl") val latestImageUrl: String?, + @SerialName("totalCount") val totalCount: Int, +) { + fun toModel() = AlbumPreview( + id = -1L, + title = "즐겨찾는사진", + thumbnailUrl = latestImageUrl, + photoCount = totalCount, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt index 97ce742fd..f9315c50d 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt @@ -5,15 +5,22 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class MediaUploadTicketResponse( - @SerialName("contentType") val contentType: String, - @SerialName("expiresIn") val expiresIn: String, - @SerialName("mediaId") val mediaId: Long, +data class MediaUploadTicketDataResponse( @SerialName("method") val method: String, - @SerialName("uploadUrl") val uploadUrl: String, + @SerialName("expiresIn") val expiresIn: String, + @SerialName("items") val items: List, ) { - fun toModel() = MediaUploadTicket( - mediaId = this.mediaId, - uploadUrl = this.uploadUrl, - ) + fun toModels() = items.map { it.toModel() } + + @Serializable + data class MediaUploadTicketItemResponse( + @SerialName("mediaId") val mediaId: Long, + @SerialName("uploadTicket") val uploadTicket: String, + @SerialName("contentType") val contentType: String, + ) { + fun toModel() = MediaUploadTicket( + mediaId = mediaId, + uploadUrl = uploadTicket, + ) + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt index 7efd9351d..a5de87fc7 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt @@ -14,6 +14,7 @@ data class PhotoResponse( data class Item( @SerialName("contentType") val contentType: String, @SerialName("createdAt") val createdAt: String, + @SerialName("favorite") val isFavorite: Boolean, @SerialName("folderId") val folderId: Long?, @SerialName("imageUrl") val imageUrl: String, @SerialName("photoId") val photoId: Long, @@ -21,6 +22,7 @@ data class PhotoResponse( internal fun toModel() = Photo( id = photoId, imageUrl = imageUrl, + isFavorite = isFavorite, date = createdAt.toFormattedDate(), ) } 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 a2854fa90..870c5f236 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt @@ -11,17 +11,22 @@ class MediaUploadRepositoryImpl @Inject constructor( private val uploadService: UploadService, ) : MediaUploadRepository { override suspend fun getUploadTicket( + uploadCount: Int, fileName: String, contentType: String, mediaType: String, ) = runSuspendCatching { uploadService.getUploadTicket( requestBody = MediaUploadTicketRequest( - filename = fileName, - contentType = contentType, - mediaType = mediaType, + items = List(uploadCount) { + MediaUploadTicketRequest.Item( + filename = fileName, + contentType = contentType, + mediaType = mediaType, + ) + }, ), - ).data.toModel() + ).data.toModels() } override suspend fun uploadImage( 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 ac207b524..3075ee168 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,10 +1,13 @@ package com.neki.android.core.data.repository.impl 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 import com.neki.android.core.data.util.runSuspendCatching 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 javax.inject.Inject class PhotoRepositoryImpl @Inject constructor( @@ -23,18 +26,42 @@ class PhotoRepositoryImpl @Inject constructor( } override suspend fun registerPhoto( - mediaId: Long, + mediaIds: List, folderId: Long?, - ): Result = runSuspendCatching { + ): Result = runSuspendCatching { photoService.registerPhoto( requestBody = RegisterPhotoRequest( - mediaId = mediaId, folderId = folderId, + uploads = mediaIds.map { RegisterPhotoRequest.Upload(mediaId = it) }, ), - ).data.photoId + ).data } override suspend fun deletePhoto(photoId: Long): Result = runSuspendCatching { - photoService.deletePhoto(photoId).data + photoService.deletePhoto( + requestBody = DeletePhotoRequest(photoIds = listOf(photoId)), + ).data + } + + override suspend fun deletePhoto(photoIds: List): Result = runSuspendCatching { + photoService.deletePhoto( + requestBody = DeletePhotoRequest(photoIds = photoIds), + ).data + } + + override suspend fun updateFavorite(photoId: Long, favorite: Boolean): Result = runSuspendCatching { + photoService.updateFavorite(photoId, favorite).data + } + + override suspend fun getFavoritePhotos( + page: Int, + size: Int, + sortOrder: SortOrder, + ): Result> = runSuspendCatching { + photoService.getFavoritePhotos(page, size, sortOrder.name).data.toModels() + } + + override suspend fun getFavoriteSummary(): Result = runSuspendCatching { + photoService.getFavoriteSummary().data.toModel() } } 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 170e53ffc..1c9664caf 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 @@ -27,7 +27,7 @@ class UploadSinglePhotoUseCase @Inject constructor( fileName = fileName, contentType = contentType.label, mediaType = MediaType.PHOTO_BOOTH.name, - ).getOrThrow() + ).getOrThrow().first() // 2. Presigned URL로 이미지 업로드 mediaUploadRepository.uploadImage( @@ -38,7 +38,7 @@ class UploadSinglePhotoUseCase @Inject constructor( // 3. 사진 등록 photoRepository.registerPhoto( - mediaId = mediaId, + mediaIds = listOf(mediaId), folderId = folderId, ).getOrThrow() diff --git a/core/model/src/main/java/com/neki/android/core/model/Album.kt b/core/model/src/main/java/com/neki/android/core/model/Album.kt index f30122021..0c53478de 100644 --- a/core/model/src/main/java/com/neki/android/core/model/Album.kt +++ b/core/model/src/main/java/com/neki/android/core/model/Album.kt @@ -11,3 +11,11 @@ data class Album( val thumbnailUrl: String? = null, val photoList: ImmutableList = persistentListOf(), ) + +@Serializable +data class AlbumPreview( + val id: Long = 0L, + val title: String = "", + val thumbnailUrl: String? = null, + val photoCount: Int = 0, +) diff --git a/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt b/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt new file mode 100644 index 000000000..4852a0a6f --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt @@ -0,0 +1,6 @@ +package com.neki.android.core.model + +enum class SortOrder { + ASC, + DESC, +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt index 5395ebbef..fd49cb2e9 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt @@ -39,9 +39,17 @@ class ResultEventBus { inline fun getResultFlow(resultKey: String = T::class.toString()) = channelMap[resultKey]?.receiveAsFlow() - inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + inline fun sendResult( + resultKey: String = T::class.toString(), + result: T, + allowDuplicate: Boolean = true, + ) { if (!channelMap.contains(resultKey)) { - channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + channelMap[resultKey] = if (allowDuplicate) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } else { + Channel(capacity = Channel.CONFLATED) + } } channelMap[resultKey]?.trySend(result) } 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 2ecec0f46..b751833b6 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 @@ -1,13 +1,14 @@ package com.neki.android.feature.archive.impl.album_detail -import com.neki.android.core.model.Album import com.neki.android.core.model.Photo import com.neki.android.feature.archive.impl.model.SelectMode import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class AlbumDetailState( - val album: Album = Album(), + val isLoading: Boolean = false, + val title: String = "", + val photoList: ImmutableList = persistentListOf(), val isFavoriteAlbum: Boolean = false, val selectMode: SelectMode = SelectMode.DEFAULT, val selectedPhotos: ImmutableList = persistentListOf(), 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 e61915709..edb0d497b 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 @@ -21,12 +21,12 @@ 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 com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet 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 -import com.neki.android.core.model.Album import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet +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.album_detail.component.EmptyContent @@ -94,8 +94,8 @@ internal fun AlbumDetailScreen( .background(NekiTheme.colorScheme.white), ) { AlbumDetailTopBar( - hasNoPhoto = uiState.album.photoList.isEmpty(), - title = if (uiState.isFavoriteAlbum) "즐겨찾는 사진" else uiState.album.title, + hasNoPhoto = uiState.photoList.isEmpty(), + title = if (uiState.isFavoriteAlbum) "즐겨찾는 사진" else uiState.title, selectMode = uiState.selectMode, onClickBack = { onIntent(AlbumDetailIntent.ClickBackIcon) }, onClickSelect = { onIntent(AlbumDetailIntent.ClickSelectButton) }, @@ -119,7 +119,7 @@ internal fun AlbumDetailScreen( horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), ) { items( - items = uiState.album.photoList, + items = uiState.photoList, key = { photo -> photo.id }, ) { photo -> val isSelected = uiState.selectedPhotos.any { it.id == photo.id } @@ -142,7 +142,11 @@ internal fun AlbumDetailScreen( ) } - if (uiState.album.photoList.isEmpty()) { + if (uiState.isLoading) { + LoadingDialog() + } + + if (!uiState.isLoading && uiState.photoList.isEmpty()) { EmptyContent( isFavorite = uiState.isFavoriteAlbum, ) @@ -216,7 +220,7 @@ private fun AlbumDetailScreenFavoriteEmptyPreview() { NekiTheme { AlbumDetailScreen( uiState = AlbumDetailState( - album = Album(id = 0, title = "즐겨찾는 사진"), + title = "즐겨찾는 사진", isFavoriteAlbum = true, ), ) @@ -229,8 +233,8 @@ private fun AlbumDetailScreenEmptyPreview() { NekiTheme { AlbumDetailScreen( uiState = AlbumDetailState( - album = Album(id = 0, title = "빈 앨범"), - isFavoriteAlbum = true, + title = "빈 앨범", + isFavoriteAlbum = false, ), ) } @@ -248,7 +252,8 @@ private fun AlbumDetailScreenFavoritePreview() { NekiTheme { AlbumDetailScreen( uiState = AlbumDetailState( - album = Album(id = 0, title = "즐겨찾는 사진", photoList = dummyPhotos), + title = "즐겨찾는 사진", + photoList = dummyPhotos, isFavoriteAlbum = true, ), ) @@ -267,7 +272,8 @@ private fun AlbumDetailScreenRegularPreview() { NekiTheme { AlbumDetailScreen( uiState = AlbumDetailState( - album = Album(id = 1, title = "네키 화이팅", photoList = dummyPhotos), + title = "네키 화이팅", + photoList = dummyPhotos, isFavoriteAlbum = false, ), ) @@ -286,7 +292,8 @@ private fun AlbumDetailScreenSelectingPreview() { NekiTheme { AlbumDetailScreen( uiState = AlbumDetailState( - album = Album(id = 1, title = "네키 화이팅", photoList = dummyPhotos), + 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 64101dd31..b72a43fe3 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 @@ -1,7 +1,8 @@ package com.neki.android.feature.archive.impl.album_detail import androidx.lifecycle.ViewModel -import com.neki.android.core.model.Album +import androidx.lifecycle.viewModelScope +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 @@ -12,11 +13,14 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +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 isFavoriteAlbum: Boolean, + private val photoRepository: PhotoRepository, ) : ViewModel() { @AssistedFactory @@ -52,7 +56,11 @@ class AlbumDetailViewModel @AssistedInject constructor( ) { when (intent) { // TopBar Intent - AlbumDetailIntent.EnterAlbumDetailScreen -> fetchInitialData(id, reduce) + AlbumDetailIntent.EnterAlbumDetailScreen -> { + if (isFavoriteAlbum) fetchFavoriteData(reduce) + else fetchAlbumData(id, reduce) + } + AlbumDetailIntent.ClickBackIcon -> handleBackClick(state, reduce, postSideEffect) AlbumDetailIntent.OnBackPressed -> handleBackClick(state, reduce, postSideEffect) AlbumDetailIntent.ClickSelectButton -> reduce { copy(selectMode = SelectMode.SELECTING) } @@ -83,16 +91,30 @@ class AlbumDetailViewModel @AssistedInject constructor( } } - private fun fetchInitialData( + private fun fetchFavoriteData( + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + photoRepository.getFavoritePhotos() + .onSuccess { data -> + reduce { copy(photoList = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + reduce { copy(isLoading = false) } + } + } + + private fun fetchAlbumData( id: Long, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, ) { // TODO: Fetch album from repository reduce { copy( - album = Album( - photoList = dummyPhotos, - ), + photoList = dummyPhotos, ) } } @@ -172,20 +194,28 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { - // TODO: Delete photos from favorite album - reduce { - copy( - album = album.copy( - photoList = album.photoList.filter { photo -> - selectedPhotos.none { it.id == photo.id } - }.toImmutableList(), - ), - selectedPhotos = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteDialog = false, - ) + viewModelScope.launch { + reduce { copy(isLoading = true) } + val selectedPhotoIds = state.selectedPhotos.map { it.id } + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + Timber.d("삭제 성공2") + fetchFavoriteData(reduce) + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteDialog = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + reduce { copy(isLoading = false) } } - postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) } private fun handleAlbumDelete( @@ -193,14 +223,11 @@ class AlbumDetailViewModel @AssistedInject constructor( reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { - // TODO: Delete photos based on selectedDeleteOption reduce { copy( - album = album.copy( - photoList = album.photoList.filter { photo -> - selectedPhotos.none { it.id == photo.id } - }.toImmutableList(), - ), + photoList = photoList.filter { photo -> + selectedPhotos.none { it.id == photo.id } + }.toImmutableList(), selectedPhotos = persistentListOf(), selectMode = SelectMode.DEFAULT, isShowDeleteBottomSheet = false, 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 268a76f7d..9a332302e 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 @@ -1,7 +1,7 @@ package com.neki.android.feature.archive.impl.main import android.net.Uri -import com.neki.android.core.model.Album +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.Photo import com.neki.android.core.model.UploadType import kotlinx.collections.immutable.ImmutableList @@ -10,8 +10,8 @@ import kotlinx.collections.immutable.persistentListOf data class ArchiveMainState( val isLoading: Boolean = false, val isFirstEntered: Boolean = true, - val favoriteAlbum: Album = Album(), - val albums: ImmutableList = persistentListOf(), + val favoriteAlbum: AlbumPreview = AlbumPreview(title = "즐겨찾는사진"), + val albums: ImmutableList = persistentListOf(), val recentPhotos: ImmutableList = persistentListOf(), val scannedImageUrl: String? = null, val selectedUris: ImmutableList = persistentListOf(), @@ -25,6 +25,7 @@ data class ArchiveMainState( sealed interface ArchiveMainIntent { data object EnterArchiveMainScreen : ArchiveMainIntent + data object RefreshArchiveMainScreen : ArchiveMainIntent data class QRCodeScanned(val imageUrl: String) : ArchiveMainIntent data object ClickScreen : ArchiveMainIntent data object ClickGoToTopButton : ArchiveMainIntent diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt index c676ffa25..d6dba07de 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 @@ -33,7 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.core.model.Album +import com.neki.android.core.model.AlbumPreview import com.neki.android.core.model.Photo import com.neki.android.core.ui.component.LoadingDialog import com.neki.android.core.ui.compose.collectWithLifecycle @@ -47,6 +47,7 @@ import com.neki.android.feature.archive.impl.main.component.ArchiveMainTitleRow import com.neki.android.feature.archive.impl.main.component.ArchiveMainTopBar import com.neki.android.feature.archive.impl.main.component.ChooseWithAlbumDialog import com.neki.android.feature.archive.impl.main.component.GotoTopButton +import com.neki.android.feature.archive.impl.main.component.NoPhotoContent import kotlinx.collections.immutable.persistentListOf import timber.log.Timber @@ -201,6 +202,8 @@ private fun ArchiveMainContent( columns = StaggeredGridCells.Fixed(2), state = lazyState, contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, bottom = ARCHIVE_LAYOUT_BOTTOM_PADDING.dp, ), verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, @@ -208,7 +211,6 @@ private fun ArchiveMainContent( ) { item(span = StaggeredGridItemSpan.FullLine) { ArchiveMainTitleRow( - modifier = Modifier.padding(horizontal = 20.dp), title = "앨범", textButtonTitle = "전체 보기", onClickShowAllAlbum = onClickShowAllAlbum, @@ -227,19 +229,23 @@ private fun ArchiveMainContent( item(span = StaggeredGridItemSpan.FullLine) { ArchiveMainTitleRow( - modifier = Modifier.padding(horizontal = 20.dp), title = "최근 사진", textButtonTitle = "모든 사진", onClickShowAllAlbum = onClickShowAllPhoto, ) } + if (uiState.recentPhotos.isEmpty()) { + item(span = StaggeredGridItemSpan.FullLine) { + NoPhotoContent() + } + } + items( uiState.recentPhotos, key = { photo -> photo.id }, ) { photo -> ArchiveMainPhotoItem( -// modifier = Modifier.padding(horizontal = 20.dp), photo = photo, onClickItem = onClickPhotoItem, ) @@ -261,42 +267,17 @@ private fun ArchiveMainScreenPreview() { Photo(id = 8, imageUrl = "https://picsum.photos/seed/photo8/200/310"), ) - val travelPhotos = persistentListOf( - Photo(id = 101, imageUrl = "https://picsum.photos/seed/travel1/200/300"), - Photo(id = 102, imageUrl = "https://picsum.photos/seed/travel2/200/280"), - Photo(id = 103, imageUrl = "https://picsum.photos/seed/travel3/200/320"), - Photo(id = 104, imageUrl = "https://picsum.photos/seed/travel4/200/260"), - ) - - val familyPhotos = persistentListOf( - Photo(id = 201, imageUrl = "https://picsum.photos/seed/family1/200/300"), - Photo(id = 202, imageUrl = "https://picsum.photos/seed/family2/200/290"), - ) - - val friendPhotos = persistentListOf( - Photo(id = 301, imageUrl = "https://picsum.photos/seed/friend1/200/300"), - Photo(id = 302, imageUrl = "https://picsum.photos/seed/friend2/200/310"), - Photo(id = 303, imageUrl = "https://picsum.photos/seed/friend3/200/280"), - ) - val dummyAlbums = persistentListOf( - Album(id = 1, title = "제주도 여행 2024", photoList = travelPhotos), - Album(id = 2, title = "가족 생일파티", photoList = familyPhotos), - Album(id = 3, title = "대학 동기 모임", photoList = friendPhotos), - ) - - val favoritePhotos = persistentListOf( - Photo(id = 401, imageUrl = "https://picsum.photos/seed/fav1/200/300"), - Photo(id = 402, imageUrl = "https://picsum.photos/seed/fav2/200/280"), - Photo(id = 403, imageUrl = "https://picsum.photos/seed/fav3/200/320"), - Photo(id = 404, imageUrl = "https://picsum.photos/seed/fav4/200/290"), - Photo(id = 405, imageUrl = "https://picsum.photos/seed/fav5/200/310"), + AlbumPreview(id = 1, title = "제주도 여행 2024", thumbnailUrl = "https://picsum.photos/seed/travel1/200/300", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", thumbnailUrl = "https://picsum.photos/seed/family1/200/300", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", thumbnailUrl = "https://picsum.photos/seed/friend1/200/300", photoCount = 3), ) - val favoriteAlbum = Album( + val favoriteAlbum = AlbumPreview( id = 0, title = "즐겨찾는 사진", - photoList = favoritePhotos, + thumbnailUrl = "https://picsum.photos/seed/fav1/200/300", + photoCount = 5, ) NekiTheme { @@ -309,3 +290,20 @@ private fun ArchiveMainScreenPreview() { ) } } + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun ArchiveMainScreenEmptyPreview() { + val favoriteAlbum = AlbumPreview( + id = 0, + title = "즐겨찾는 사진", + ) + + NekiTheme { + ArchiveMainScreen( + uiState = ArchiveMainState( + favoriteAlbum = favoriteAlbum, + ), + ) + } +} 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 30132da11..6b1c13d4e 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 @@ -6,14 +6,15 @@ import androidx.lifecycle.viewModelScope import com.neki.android.core.common.util.urlToByteArray import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase -import com.neki.android.core.model.Album -import com.neki.android.core.model.Photo import com.neki.android.core.model.UploadType import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,6 +34,7 @@ class ArchiveMainViewModel @Inject constructor( initialFetchData = { store.onIntent(ArchiveMainIntent.EnterArchiveMainScreen) }, ) + private var fetchJob: Job? = null private fun onIntent( intent: ArchiveMainIntent, state: ArchiveMainState, @@ -49,6 +51,7 @@ class ArchiveMainViewModel @Inject constructor( } ArchiveMainIntent.EnterArchiveMainScreen -> fetchInitialData(reduce) + ArchiveMainIntent.RefreshArchiveMainScreen -> fetchInitialData(reduce) ArchiveMainIntent.ClickScreen -> reduce { copy(isFirstEntered = false) } ArchiveMainIntent.ClickGoToTopButton -> postSideEffect(ArchiveMainSideEffect.ScrollToTop) @@ -113,69 +116,39 @@ class ArchiveMainViewModel @Inject constructor( } private fun fetchInitialData(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { - val dummyPhotos = listOf( - Photo(id = 1, imageUrl = "https://picsum.photos/seed/pose1/400/500", isFavorite = true, date = "2025.01.15"), - Photo(id = 2, imageUrl = "https://picsum.photos/seed/pose2/400/650", isFavorite = false, date = "2025.01.15"), - Photo(id = 3, imageUrl = "https://picsum.photos/seed/pose3/400/480", isFavorite = true, date = "2025.01.14"), - Photo(id = 4, imageUrl = "https://picsum.photos/seed/pose4/400/720", isFavorite = false, date = "2025.01.14"), - Photo(id = 5, imageUrl = "https://picsum.photos/seed/pose5/400/550", isFavorite = false, date = "2025.01.13"), - Photo(id = 6, imageUrl = "https://picsum.photos/seed/pose6/400/400", isFavorite = true, date = "2025.01.13"), - Photo(id = 7, imageUrl = "https://picsum.photos/seed/pose7/400/600", isFavorite = false, date = "2025.01.12"), - Photo(id = 8, imageUrl = "https://picsum.photos/seed/pose8/400/520", isFavorite = false, date = "2025.01.12"), - Photo(id = 9, imageUrl = "https://picsum.photos/seed/pose9/400/680", isFavorite = true, date = "2025.01.11"), - Photo(id = 10, imageUrl = "https://picsum.photos/seed/pose10/400/450", isFavorite = false, date = "2025.01.11"), - Photo(id = 11, imageUrl = "https://picsum.photos/seed/pose11/400/580", isFavorite = false, date = "2025.01.10"), - Photo(id = 12, imageUrl = "https://picsum.photos/seed/pose12/400/700", isFavorite = true, date = "2025.01.10"), - Photo(id = 13, imageUrl = "https://picsum.photos/seed/pose13/400/460", isFavorite = false, date = "2025.01.09"), - Photo(id = 14, imageUrl = "https://picsum.photos/seed/pose14/400/620", isFavorite = false, date = "2025.01.09"), - Photo(id = 15, imageUrl = "https://picsum.photos/seed/pose15/400/540", isFavorite = true, date = "2025.01.08"), - ).toImmutableList() + if (fetchJob?.isActive == true) return - 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"), - ) - - 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 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), - ) - - val favoritePhotos = dummyPhotos.filter { it.isFavorite }.toImmutableList() - - val favoriteAlbum = Album( - id = 0, - title = "즐겨찾는 사진", - thumbnailUrl = favoritePhotos.firstOrNull()?.imageUrl, - photoList = favoritePhotos, - ) + fetchJob = viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchPhotos(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } + } + } - fetchPhotos(reduce) + private suspend fun fetchFavoriteSummary(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } - reduce { - copy( - favoriteAlbum = favoriteAlbum, - albums = dummyAlbums, -// recentPhotos = dummyPhotos, - ) - } + private suspend fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) { + photoRepository.getPhotos() + .onSuccess { data -> + reduce { copy(recentPhotos = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } } private fun uploadWithoutAlbum( @@ -185,6 +158,7 @@ class ArchiveMainViewModel @Inject constructor( ) { reduce { copy(isShowChooseWithAlbumDialog = false) } val onSuccessSideEffect = { + reduce { copy(isLoading = false) } postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요")) } if (state.uploadType == UploadType.QR_SCAN) { @@ -237,23 +211,6 @@ class ArchiveMainViewModel @Inject constructor( postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) } - private fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) { - viewModelScope.launch { - photoRepository.getPhotos() - .onSuccess { data -> - reduce { - copy( - recentPhotos = data.toImmutableList(), - isLoading = false, - ) - } - } - .onFailure { error -> - Timber.e(error) - } - } - } - private fun handleAddAlbum( albumName: String, reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, 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 0b25c09c5..0e8bd4215 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 @@ -40,7 +40,7 @@ import com.neki.android.core.designsystem.R import com.neki.android.core.designsystem.modifier.cardShadow 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.AlbumPreview import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -49,9 +49,9 @@ private const val VIEWPORT_H = 65f @Composable internal fun ArchiveMainAlbumList( - favoriteAlbum: Album, + favoriteAlbum: AlbumPreview, modifier: Modifier = Modifier, - albumList: ImmutableList = persistentListOf(), + albumList: ImmutableList = persistentListOf(), onClickFavoriteAlbum: () -> Unit = {}, onClickAlbumItem: (Long) -> Unit = {}, ) { @@ -63,7 +63,7 @@ internal fun ArchiveMainAlbumList( item(key = "favorite_album") { ArchiveAlbumItem( title = favoriteAlbum.title, - photoCount = favoriteAlbum.photoList.size, + photoCount = favoriteAlbum.photoCount, thumbnailImage = favoriteAlbum.thumbnailUrl, isFavorite = true, onClick = onClickFavoriteAlbum, @@ -75,7 +75,7 @@ internal fun ArchiveMainAlbumList( ) { album -> ArchiveAlbumItem( title = album.title, - photoCount = album.photoList.size, + photoCount = album.photoCount, thumbnailImage = album.thumbnailUrl, onClick = { onClickAlbumItem(album.id) }, ) @@ -270,7 +270,7 @@ private fun DefaultAlbumFolderPreview() { private fun ArchiveMainAlbumListPreview() { NekiTheme { ArchiveMainAlbumList( - favoriteAlbum = Album(), + favoriteAlbum = AlbumPreview(), ) } } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt new file mode 100644 index 000000000..cfc049feb --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt @@ -0,0 +1,47 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun NoPhotoContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 70.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(104.dp) + .background( + color = NekiTheme.colorScheme.gray50, + shape = CircleShape, + ), + ) + Text( + text = "아직 등록된 사진이 없어요\n찍은 네컷을 네키에 저장해보세요!", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray300, + textAlign = TextAlign.Center, + ) + } +} 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 8907ccbed..7ebcad404 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 @@ -47,6 +47,9 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { ResultEffect(resultBus) { imageUrl -> viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(imageUrl)) } + ResultEffect(resultBus) { hasUpdated -> + if (hasUpdated) viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMainScreen) + } ArchiveMainRoute( viewModel = viewModel, 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 557e56d9c..c9a3b9abb 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 @@ -4,6 +4,7 @@ import com.neki.android.core.model.Photo data class PhotoDetailState( val isLoading: Boolean = false, + val committedFavorite: Boolean = false, val photo: Photo = Photo(), val isShowDeleteDialog: Boolean = false, ) @@ -15,6 +16,8 @@ sealed interface PhotoDetailIntent { // ActionBar Intent data object ClickDownloadIcon : PhotoDetailIntent data object ClickFavoriteIcon : PhotoDetailIntent + data class FavoriteCommitted(val newFavorite: Boolean) : PhotoDetailIntent + data class RevertFavorite(val originalFavorite: Boolean) : PhotoDetailIntent data object ClickDeleteIcon : PhotoDetailIntent // Delete Dialog Intent @@ -25,6 +28,7 @@ sealed interface PhotoDetailIntent { sealed interface PhotoDetailSideEffect { data object NavigateBack : PhotoDetailSideEffect + data object NotifyArchiveUpdated : 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 6bddcbfd1..2bd261e4d 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 @@ -19,6 +19,7 @@ import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Photo import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.navigation.result.LocalResultEventBus 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 @@ -33,14 +34,13 @@ internal fun PhotoDetailRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { PhotoDetailSideEffect.NavigateBack -> navigateBack() - is PhotoDetailSideEffect.ShowToastMessage -> { - nekiToast.showToast(text = sideEffect.message) - } - + PhotoDetailSideEffect.NotifyArchiveUpdated -> resultEventBus.sendResult(result = true, allowDuplicate = false) + is PhotoDetailSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) is PhotoDetailSideEffect.DownloadImage -> { ImageDownloader.downloadImage(context, sideEffect.imageUrl) .onSuccess { 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 ead625b94..c1dd6da20 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 @@ -2,6 +2,7 @@ package com.neki.android.feature.archive.impl.photo_detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.Photo import com.neki.android.core.ui.MviIntentStore @@ -10,26 +11,54 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import timber.log.Timber +@OptIn(FlowPreview::class) @HiltViewModel(assistedFactory = PhotoDetailViewModel.Factory::class) class PhotoDetailViewModel @AssistedInject constructor( @Assisted private val photo: Photo, private val photoRepository: PhotoRepository, + @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(photo: Photo): PhotoDetailViewModel - } - + private val favoriteRequests = MutableSharedFlow(extraBufferCapacity = 64) val store: MviIntentStore = mviIntentStore( - initialState = PhotoDetailState(photo = photo), + initialState = PhotoDetailState(photo = photo, committedFavorite = photo.isFavorite), onIntent = ::onIntent, ) + init { + viewModelScope.launch { + favoriteRequests + .debounce(500) + .collect { newFavorite -> + val committedFavorite = store.uiState.value.committedFavorite + if (committedFavorite != newFavorite) { + photoRepository.updateFavorite(photo.id, newFavorite) + .onSuccess { + Timber.d("updateFavorite success") + store.onIntent(PhotoDetailIntent.FavoriteCommitted(newFavorite)) + } + .onFailure { error -> + Timber.e(error, "updateFavorite failed") + store.onIntent(PhotoDetailIntent.RevertFavorite(committedFavorite)) + } + } + } + } + } + + @AssistedFactory + interface Factory { + fun create(photo: Photo): PhotoDetailViewModel + } + private fun onIntent( intent: PhotoDetailIntent, state: PhotoDetailState, @@ -42,7 +71,13 @@ class PhotoDetailViewModel @AssistedInject constructor( // ActionBar Intent PhotoDetailIntent.ClickDownloadIcon -> postSideEffect(PhotoDetailSideEffect.DownloadImage(state.photo.imageUrl)) - PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce, postSideEffect) + PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce) + is PhotoDetailIntent.FavoriteCommitted -> { + reduce { copy(committedFavorite = intent.newFavorite) } + postSideEffect(PhotoDetailSideEffect.NotifyArchiveUpdated) + } + + is PhotoDetailIntent.RevertFavorite -> reduce { copy(photo = photo.copy(isFavorite = intent.originalFavorite)) } PhotoDetailIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } // Delete Dialog Intent @@ -55,10 +90,9 @@ class PhotoDetailViewModel @AssistedInject constructor( private fun handleFavoriteToggle( state: PhotoDetailState, reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, - postSideEffect: (PhotoDetailSideEffect) -> Unit, ) { val newFavoriteStatus = !state.photo.isFavorite - // TODO: Update favorite status in repository + viewModelScope.launch { favoriteRequests.emit(newFavoriteStatus) } reduce { copy(photo = state.photo.copy(isFavorite = newFavoriteStatus)) } @@ -75,6 +109,7 @@ class PhotoDetailViewModel @AssistedInject constructor( photoRepository.deletePhoto(state.photo.id) .onSuccess { reduce { copy(isLoading = false) } + postSideEffect(PhotoDetailSideEffect.NotifyArchiveUpdated) postSideEffect(PhotoDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) postSideEffect(PhotoDetailSideEffect.NavigateBack) } @@ -85,4 +120,17 @@ class PhotoDetailViewModel @AssistedInject constructor( } } } + + override fun onCleared() { + super.onCleared() + + val currentFavorite = store.uiState.value.photo.isFavorite + val committedFavorite = store.uiState.value.committedFavorite + + if (currentFavorite != committedFavorite) { + applicationScope.launch { + photoRepository.updateFavorite(photo.id, currentFavorite) + } + } + } }