From 6ed1b28a8da4c12d00dd26a9228421b08362fb43 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:32:07 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `domain/collection`: 컬렉션 도메인 모듈 신규 생성 및 설정 - `Collection.kt`: - `Collection`, `CollectionDetail`, `CollectionNovel` 등 도메인 데이터 모델 정의 - `SaveCollection` 모델 내 비즈니스 제약 조건(이름/설명 길이, 작품 수, 대표 작품 유효성 등) 검증 로직 구현 - `CollectionRepository.kt`: 컬렉션 목록 조회(Paging), 상세 정보 조회, CRUD 및 좋아요 관리를 위한 인터페이스 정의 - `SaveCollectionTest.kt`: `SaveCollection` 모델의 생성 및 유효성 검증 로직에 대한 단위 테스트 작성 - `settings.gradle.kts` & `feature/collection/build.gradle.kts`: `:domain:collection` 및 `:data:collection` 모듈 등록 및 프로젝트 내 의존성 연결 --- domain/collection/build.gradle.kts | 15 ++++ .../collection/src/main/AndroidManifest.xml | 1 + .../domain/collection/CollectionRepository.kt | 40 ++++++++++ .../domain/collection/model/Collection.kt | 74 +++++++++++++++++++ .../collection/model/SaveCollectionTest.kt | 53 +++++++++++++ feature/collection/build.gradle.kts | 3 + settings.gradle.kts | 2 + 7 files changed, 188 insertions(+) create mode 100644 domain/collection/build.gradle.kts create mode 100644 domain/collection/src/main/AndroidManifest.xml create mode 100644 domain/collection/src/main/java/com/into/websoso/domain/collection/CollectionRepository.kt create mode 100644 domain/collection/src/main/java/com/into/websoso/domain/collection/model/Collection.kt create mode 100644 domain/collection/src/test/java/com/into/websoso/domain/collection/model/SaveCollectionTest.kt diff --git a/domain/collection/build.gradle.kts b/domain/collection/build.gradle.kts new file mode 100644 index 000000000..2d2b04e7f --- /dev/null +++ b/domain/collection/build.gradle.kts @@ -0,0 +1,15 @@ +import com.into.websoso.setNamespace + +plugins { + id("websoso.android.library") +} + +android { + setNamespace("domain.collection") +} + +dependencies { + implementation(libs.paging.runtime) + + testImplementation(libs.junit) +} diff --git a/domain/collection/src/main/AndroidManifest.xml b/domain/collection/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/domain/collection/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/domain/collection/src/main/java/com/into/websoso/domain/collection/CollectionRepository.kt b/domain/collection/src/main/java/com/into/websoso/domain/collection/CollectionRepository.kt new file mode 100644 index 000000000..495c2c496 --- /dev/null +++ b/domain/collection/src/main/java/com/into/websoso/domain/collection/CollectionRepository.kt @@ -0,0 +1,40 @@ +package com.into.websoso.domain.collection + +import androidx.paging.PagingData +import com.into.websoso.domain.collection.model.Collection +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionPage +import com.into.websoso.domain.collection.model.CollectionSortCriteria +import com.into.websoso.domain.collection.model.SaveCollection +import kotlinx.coroutines.flow.Flow + +interface CollectionRepository { + fun getMyCollections(): Flow> + + fun getUserCollections(userId: Long): Flow> + + fun getLikedCollections(): Flow> + + suspend fun getMyCollectionPreview(): CollectionPage + + suspend fun getUserCollectionPreview(userId: Long): CollectionPage + + suspend fun getCollection( + collectionId: Long, + sortCriteria: CollectionSortCriteria = CollectionSortCriteria.RECENT, + ): CollectionDetail + + suspend fun createCollection(collection: SaveCollection): Long + + suspend fun updateCollection( + collectionId: Long, + collection: SaveCollection, + ) + + suspend fun deleteCollection(collectionId: Long) + + suspend fun updateLike( + collectionId: Long, + isLiked: Boolean, + ) +} diff --git a/domain/collection/src/main/java/com/into/websoso/domain/collection/model/Collection.kt b/domain/collection/src/main/java/com/into/websoso/domain/collection/model/Collection.kt new file mode 100644 index 000000000..40c361f12 --- /dev/null +++ b/domain/collection/src/main/java/com/into/websoso/domain/collection/model/Collection.kt @@ -0,0 +1,74 @@ +package com.into.websoso.domain.collection.model + +data class Collection( + val id: Long, + val name: String, + val description: String?, + val isPublic: Boolean, + val novelCount: Int, + val likeCount: Int?, + val representativeNovel: CollectionNovel, + val recentNovels: List, +) + +data class CollectionNovel( + val id: Long, + val title: String, + val imageUrl: String, + val author: String, +) + +data class CollectionOwner( + val id: Long, + val nickname: String, + val avatarImageUrl: String, +) + +data class CollectionDetail( + val id: Long, + val name: String, + val description: String?, + val isPublic: Boolean, + val isMine: Boolean, + val owner: CollectionOwner, + val representativeNovelId: Long, + val novelCount: Int, + val likeCount: Int, + val isLiked: Boolean, + val novels: List, +) + +data class CollectionPage( + val totalCount: Int, + val hasNext: Boolean, + val nextCursor: String?, + val collections: List, +) + +data class SaveCollection( + val name: String, + val description: String, + val isPublic: Boolean, + val novelIds: List, + val representativeNovelId: Long, +) { + init { + require(name.isNotBlank() && name.length <= NAME_MAX_LENGTH) + require(description.length <= DESCRIPTION_MAX_LENGTH) + require(novelIds.size in MIN_NOVEL_COUNT..MAX_NOVEL_COUNT) + require(novelIds.distinct().size == novelIds.size) + require(representativeNovelId in novelIds) + } + + private companion object { + const val NAME_MAX_LENGTH = 20 + const val DESCRIPTION_MAX_LENGTH = 60 + const val MIN_NOVEL_COUNT = 1 + const val MAX_NOVEL_COUNT = 100 + } +} + +enum class CollectionSortCriteria { + RECENT, + OLD, +} diff --git a/domain/collection/src/test/java/com/into/websoso/domain/collection/model/SaveCollectionTest.kt b/domain/collection/src/test/java/com/into/websoso/domain/collection/model/SaveCollectionTest.kt new file mode 100644 index 000000000..5417b2989 --- /dev/null +++ b/domain/collection/src/test/java/com/into/websoso/domain/collection/model/SaveCollectionTest.kt @@ -0,0 +1,53 @@ +package com.into.websoso.domain.collection.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SaveCollectionTest { + @Test + fun `작품은 1개부터 100개까지 중복 없이 저장한다`() { + listOf(emptyList(), List(101) { it.toLong() }, listOf(1L, 1L)).forEach { ids -> + assertThrows(IllegalArgumentException::class.java) { + SaveCollection("컬렉션", "", true, ids, 1L) + } + } + listOf(listOf(1L), (1L..100L).toList()).forEach { ids -> + assertEquals(ids, SaveCollection("컬렉션", "", true, ids, 1L).novelIds) + } + } + + @Test + fun `대표 작품 변경은 등록 순서를 바꾸지 않는다`() { + val collection = SaveCollection("컬렉션", "", false, listOf(3L, 1L, 2L), 3L) + val updated = collection.copy(representativeNovelId = 1L) + assertEquals(listOf(3L, 1L, 2L), updated.novelIds) + assertEquals(false, updated.isPublic) + } + + @Test + fun `이름과 설명 길이를 검증한다`() { + listOf("", " ", "가".repeat(21)).forEach { name -> + assertThrows(IllegalArgumentException::class.java) { + SaveCollection(name, "", true, listOf(1L), 1L) + } + } + assertThrows(IllegalArgumentException::class.java) { + SaveCollection("컬렉션", "가".repeat(61), true, listOf(1L), 1L) + } + assertEquals(60, SaveCollection("가".repeat(20), "가".repeat(60), true, listOf(1L), 1L).description.length) + } + + @Test + fun `대표 작품이 작품 목록에 없으면 생성할 수 없다`() { + assertThrows(IllegalArgumentException::class.java) { + SaveCollection( + name = "컬렉션", + description = "", + isPublic = true, + novelIds = listOf(1L), + representativeNovelId = 2L, + ) + } + } +} diff --git a/feature/collection/build.gradle.kts b/feature/collection/build.gradle.kts index bcdee05eb..eed01caa6 100644 --- a/feature/collection/build.gradle.kts +++ b/feature/collection/build.gradle.kts @@ -11,7 +11,10 @@ android { dependencies { implementation(projects.data.library) implementation(projects.data.novel) + implementation(projects.domain.collection) implementation(libs.navigation.compose) implementation(libs.paging.compose) + + testImplementation(libs.junit) } diff --git a/settings.gradle.kts b/settings.gradle.kts index e814f4de1..30203a9c2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,12 +32,14 @@ include( include( ":data:account", + ":data:collection", ":data:library", ":data:novel", ":data:feed", ) include( + ":domain:collection", ":domain:library", ) From 5764f20953475406b50d7c0f98207ca2cbdbe0d5 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:38:14 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionApi.kt`: 컬렉션 생성, 상세 조회, 수정, 삭제, 좋아요 및 사용자별 컬렉션 목록 조회를 위한 API 엔드포인트 정의 - `CollectionDto.kt`: API 요청/응답을 위한 DTO 정의 및 도메인 모델로의 매핑 로직(`toDomain`, `toRequest`) 구현 - `DefaultCollectionRepository.kt`: `CollectionRepository` 인터페이스 구현 및 Paging을 활용한 컬렉션 목록 처리 로직 추가 - `CollectionPagingSource.kt`: Paging3 라이브러리를 사용한 커서 기반 페이징 구현 - `CollectionApiModule.kt` & `CollectionRepositoryModule.kt`: Hilt를 이용한 API 인터페이스 및 레포지토리 의존성 주입 설정 - `app/build.gradle.kts`: 앱 모듈에 `data:collection` 프로젝트 의존성 추가 --- app/build.gradle.kts | 1 + data/collection/src/main/AndroidManifest.xml | 1 + .../websoso/data/collection/CollectionApi.kt | 56 ++++++++ .../data/collection/CollectionApiModule.kt | 16 +++ .../websoso/data/collection/CollectionDto.kt | 133 ++++++++++++++++++ .../data/collection/CollectionPagingSource.kt | 30 ++++ .../collection/CollectionRepositoryModule.kt | 16 +++ .../collection/DefaultCollectionRepository.kt | 94 +++++++++++++ 8 files changed, 347 insertions(+) create mode 100644 data/collection/src/main/AndroidManifest.xml create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/CollectionApi.kt create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/CollectionApiModule.kt create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/CollectionDto.kt create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/CollectionPagingSource.kt create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/CollectionRepositoryModule.kt create mode 100644 data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7fe2bf5cd..19e017c57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(projects.core.datastore) implementation(projects.data.account) + implementation(projects.data.collection) implementation(projects.data.library) implementation(projects.data.feed) diff --git a/data/collection/src/main/AndroidManifest.xml b/data/collection/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/data/collection/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApi.kt b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApi.kt new file mode 100644 index 000000000..d5a1dbe45 --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApi.kt @@ -0,0 +1,56 @@ +package com.into.websoso.data.collection + +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +internal interface CollectionApi { + @POST("collections") + suspend fun createCollection( + @Body request: SaveCollectionRequestDto, + ): CreateCollectionResponseDto + + @GET("collections/{collectionId}") + suspend fun getCollection( + @Path("collectionId") collectionId: Long, + @Query("sortCriteria") sortCriteria: String, + ): CollectionDetailResponseDto + + @PUT("collections/{collectionId}") + suspend fun updateCollection( + @Path("collectionId") collectionId: Long, + @Body request: SaveCollectionRequestDto, + ) + + @DELETE("collections/{collectionId}") + suspend fun deleteCollection( + @Path("collectionId") collectionId: Long, + ) + + @PUT("collections/{collectionId}/likes") + suspend fun likeCollection( + @Path("collectionId") collectionId: Long, + ) + + @DELETE("collections/{collectionId}/likes") + suspend fun unlikeCollection( + @Path("collectionId") collectionId: Long, + ) + + @GET("users/me/liked-collections") + suspend fun getLikedCollections( + @Query("cursor") cursor: String?, + @Query("size") size: Int, + ): CollectionPageResponseDto + + @GET("users/{userId}/collections") + suspend fun getUserCollections( + @Path("userId") userId: Long, + @Query("cursor") cursor: String?, + @Query("size") size: Int, + ): CollectionPageResponseDto +} diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApiModule.kt b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApiModule.kt new file mode 100644 index 000000000..eb1f71be7 --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionApiModule.kt @@ -0,0 +1,16 @@ +package com.into.websoso.data.collection + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CollectionApiModule { + @Provides + @Singleton + fun provideCollectionApi(retrofit: Retrofit): CollectionApi = retrofit.create(CollectionApi::class.java) +} diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/CollectionDto.kt b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionDto.kt new file mode 100644 index 000000000..e09f89cb8 --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionDto.kt @@ -0,0 +1,133 @@ +package com.into.websoso.data.collection + +import com.into.websoso.domain.collection.model.Collection +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionNovel +import com.into.websoso.domain.collection.model.CollectionOwner +import com.into.websoso.domain.collection.model.CollectionPage +import com.into.websoso.domain.collection.model.SaveCollection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class SaveCollectionRequestDto( + @SerialName("name") val name: String, + @SerialName("description") val description: String, + @SerialName("isPublic") val isPublic: Boolean, + @SerialName("novelIds") val novelIds: List, + @SerialName("representativeNovelId") val representativeNovelId: Long, +) + +internal fun SaveCollection.toRequest() = + SaveCollectionRequestDto( + name = name, + description = description, + isPublic = isPublic, + novelIds = novelIds, + representativeNovelId = representativeNovelId, + ) + +@Serializable +internal data class CreateCollectionResponseDto( + @SerialName("collectionId") val collectionId: Long, +) + +@Serializable +internal data class CollectionNovelDto( + @SerialName("novelId") val novelId: Long, + @SerialName("title") val title: String, + @SerialName("novelImage") val novelImage: String, + @SerialName("author") val author: String, +) { + fun toDomain() = + CollectionNovel( + id = novelId, + title = title, + imageUrl = novelImage, + author = author, + ) +} + +@Serializable +internal data class CollectionResponseDto( + @SerialName("collectionId") val collectionId: Long, + @SerialName("collectionName") val collectionName: String, + @SerialName("collectionDescription") val collectionDescription: String? = null, + @SerialName("isPublic") val isPublic: Boolean, + @SerialName("novelCount") val novelCount: Int, + @SerialName("likeCount") val likeCount: Int? = null, + @SerialName("representativeNovel") val representativeNovel: CollectionNovelDto, + @SerialName("recentNovels") val recentNovels: List, +) { + fun toDomain() = + Collection( + id = collectionId, + name = collectionName, + description = collectionDescription, + isPublic = isPublic, + novelCount = novelCount, + likeCount = likeCount, + representativeNovel = representativeNovel.toDomain(), + recentNovels = recentNovels.map(CollectionNovelDto::toDomain), + ) +} + +@Serializable +internal data class CollectionPageResponseDto( + @SerialName("collectionsCount") val collectionsCount: Int, + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("collections") val collections: List, +) { + fun toDomain() = + CollectionPage( + totalCount = collectionsCount, + hasNext = hasNext, + nextCursor = nextCursor, + collections = collections.map(CollectionResponseDto::toDomain), + ) +} + +@Serializable +internal data class CollectionOwnerDto( + @SerialName("userId") val userId: Long, + @SerialName("nickname") val nickname: String, + @SerialName("avatarImage") val avatarImage: String, +) { + fun toDomain() = + CollectionOwner( + id = userId, + nickname = nickname, + avatarImageUrl = avatarImage, + ) +} + +@Serializable +internal data class CollectionDetailResponseDto( + @SerialName("collectionId") val collectionId: Long, + @SerialName("collectionName") val collectionName: String, + @SerialName("collectionDescription") val collectionDescription: String? = null, + @SerialName("isPublic") val isPublic: Boolean, + @SerialName("isMyCollection") val isMyCollection: Boolean, + @SerialName("owner") val owner: CollectionOwnerDto, + @SerialName("representativeNovelId") val representativeNovelId: Long, + @SerialName("novelCount") val novelCount: Int, + @SerialName("likeCount") val likeCount: Int, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("novels") val novels: List, +) { + fun toDomain() = + CollectionDetail( + id = collectionId, + name = collectionName, + description = collectionDescription, + isPublic = isPublic, + isMine = isMyCollection, + owner = owner.toDomain(), + representativeNovelId = representativeNovelId, + novelCount = novelCount, + likeCount = likeCount, + isLiked = isLiked, + novels = novels.map(CollectionNovelDto::toDomain), + ) +} diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/CollectionPagingSource.kt b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionPagingSource.kt new file mode 100644 index 000000000..32bb3e5ac --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionPagingSource.kt @@ -0,0 +1,30 @@ +package com.into.websoso.data.collection + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.into.websoso.domain.collection.model.Collection +import com.into.websoso.domain.collection.model.CollectionPage +import kotlinx.coroutines.CancellationException + +internal class CollectionPagingSource( + private val getPage: suspend (cursor: String?, size: Int) -> CollectionPage, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = + runCatching { + getPage(params.key, params.loadSize) + }.fold( + onSuccess = { page -> + LoadResult.Page( + data = page.collections, + prevKey = null, + nextKey = page.nextCursor.takeIf { page.hasNext }, + ) + }, + onFailure = { throwable -> + if (throwable is CancellationException) throw throwable + LoadResult.Error(throwable) + }, + ) + + override fun getRefreshKey(state: PagingState): String? = null +} diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/CollectionRepositoryModule.kt b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionRepositoryModule.kt new file mode 100644 index 000000000..a9111ef76 --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/CollectionRepositoryModule.kt @@ -0,0 +1,16 @@ +package com.into.websoso.data.collection + +import com.into.websoso.domain.collection.CollectionRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class CollectionRepositoryModule { + @Binds + @Singleton + abstract fun bindCollectionRepository(repository: DefaultCollectionRepository): CollectionRepository +} diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt b/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt new file mode 100644 index 000000000..34a06333c --- /dev/null +++ b/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt @@ -0,0 +1,94 @@ +package com.into.websoso.data.collection + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.into.websoso.data.account.AccountRepository +import com.into.websoso.domain.collection.CollectionRepository +import com.into.websoso.domain.collection.model.Collection +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionPage +import com.into.websoso.domain.collection.model.CollectionSortCriteria +import com.into.websoso.domain.collection.model.SaveCollection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +internal class DefaultCollectionRepository + @Inject + constructor( + private val api: CollectionApi, + private val accountRepository: AccountRepository, + ) : CollectionRepository { + override fun getMyCollections(): Flow> = + createPager { cursor, size -> + getUserCollectionPage( + userId = accountRepository.userIdFlow.first { it != 0L }, + cursor = cursor, + size = size, + ) + } + + override fun getUserCollections(userId: Long): Flow> = + createPager { cursor, size -> getUserCollectionPage(userId, cursor, size) } + + override fun getLikedCollections(): Flow> = + createPager { cursor, size -> + api.getLikedCollections(cursor, size).toDomain() + } + + override suspend fun getMyCollectionPreview(): CollectionPage = + getUserCollectionPage( + userId = accountRepository.userIdFlow.first { it != 0L }, + cursor = null, + size = PREVIEW_SIZE, + ) + + override suspend fun getUserCollectionPreview(userId: Long): CollectionPage = + getUserCollectionPage(userId = userId, cursor = null, size = PREVIEW_SIZE) + + override suspend fun getCollection( + collectionId: Long, + sortCriteria: CollectionSortCriteria, + ): CollectionDetail = api.getCollection(collectionId, sortCriteria.name).toDomain() + + override suspend fun createCollection(collection: SaveCollection): Long = api.createCollection(collection.toRequest()).collectionId + + override suspend fun updateCollection( + collectionId: Long, + collection: SaveCollection, + ) { + api.updateCollection(collectionId, collection.toRequest()) + } + + override suspend fun deleteCollection(collectionId: Long) { + api.deleteCollection(collectionId) + } + + override suspend fun updateLike( + collectionId: Long, + isLiked: Boolean, + ) { + if (isLiked) api.likeCollection(collectionId) else api.unlikeCollection(collectionId) + } + + private fun createPager(getPage: suspend (cursor: String?, size: Int) -> CollectionPage): Flow> = + Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { CollectionPagingSource(getPage) }, + ).flow + + private suspend fun getUserCollectionPage( + userId: Long, + cursor: String?, + size: Int, + ): CollectionPage = api.getUserCollections(userId, cursor, size).toDomain() + + private companion object { + const val PAGE_SIZE = 10 + const val PREVIEW_SIZE = 3 + } + } From 7730dcc668a6436ae5fd9ccfe9e21f8e78fcfd1c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:50:38 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A6=AC=EC=86=8C=EC=8A=A4(=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98,=20=EC=9D=B4=EB=AF=B8=EC=A7=80,=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `core/resource`: - 컬렉션 비어 있음(`ic_collection_empty.xml`, `img_collection_empty_cover.png`) 및 경고(`ic_collection_warning.xml`) 관련 그래픽 리소스 추가 - `strings.xml`: - 컬렉션 상태 메시지 추가 (비어 있음, 로딩 실패, 생성/수정/삭제 실패 등) - 컬렉션 관리 UI 텍스트 추가 (컬렉션 만들기, 수정, 삭제, 공유, 정렬 등) - 삭제/수정 중단 확인 팝업용 문구 및 작품 수 표시 형식 정의 --- .../main/res/drawable/ic_collection_empty.xml | 14 +++++++++ .../res/drawable/ic_collection_warning.xml | 12 ++++++++ .../drawable/img_collection_empty_cover.png | Bin 0 -> 10961 bytes core/resource/src/main/res/values/strings.xml | 29 ++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 core/resource/src/main/res/drawable/ic_collection_empty.xml create mode 100644 core/resource/src/main/res/drawable/ic_collection_warning.xml create mode 100644 core/resource/src/main/res/drawable/img_collection_empty_cover.png diff --git a/core/resource/src/main/res/drawable/ic_collection_empty.xml b/core/resource/src/main/res/drawable/ic_collection_empty.xml new file mode 100644 index 000000000..07b5b3f07 --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_collection_empty.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/core/resource/src/main/res/drawable/ic_collection_warning.xml b/core/resource/src/main/res/drawable/ic_collection_warning.xml new file mode 100644 index 000000000..a5b5a5f53 --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_collection_warning.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/resource/src/main/res/drawable/img_collection_empty_cover.png b/core/resource/src/main/res/drawable/img_collection_empty_cover.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a337116f8a76a8bbb459cb85c196881b9e0402 GIT binary patch literal 10961 zcmeHt`6JX_)c9wHv5lx~m5Ndkk?dJVwid<|hRBj0`&O9j3?77v9$5>6WF1U)#u|ET zA^W~%HyArJ#{1Fx4}8BryuUp^Ff-?#bMLw5oO|xM=bUg9QtK4U1r`7RPCdA<{saJ^ zC;*_dVqyRx#aDl}_|0PgE2e~<@Hu5Exoh}#pbdqBa@i%Z}SdK*<8RRADF z!Ku$-03dArKwVYe6G9rFEKD}^nj)8$2SiJKjS!V4WY2tg_$9F-_I~CQJ7en$^XYSz zEPs9F$*8JI(bp5|uS-@O~;`WSJ_{Ud+#hkD4(l5l}&jxVzxAh{>}tZmUPD=Ihl1lmnaqpTdpSSMO`;Z16UC4}JNKKD~vtnw0|A_1)K%9>gs) z0DOBNXE@=NMLhT&qf2}$(ea74xEfZNG;MpJ4`U8c*P)$PHEx*#LvwNq*AxDjz^seGX8o9(Ivtf&DjKz+To=B!)Ky|AMlHM%wf7*LNL_n{7bP~-WBjWv4-i1ma5 z47XBBHamJ3XxmeDWnJ6p9WQ1BL;>Jqs_7^#_K3%+)&{oh_ajQt000u?ZAI4OxP-FS z0ARx5IEQG0#I(?dD{3l3xII&0Hw;764qd z1(ouncrQy5rK^Nlo7lU=Yc2+Y=R0`?Hzz(9ATFsqQGN^n70Es}ol>W=du+GKP@tal zZjZk2FDY+Ol`l;)BxuP?Zu>WB?O|9oTni`^c5Tc{6o(P zApXHZf%|8{bNK+u0`NaTK!9t^Alfwu42Zt=AJTs!_|FRdI|%=$z5sDMS#gKvdb|_^ z!$r|^b}Q_CuE_KKU8h{OwY$Wt)GOy39 zou)d(?-~wk_69M+rz?4t<%Gc9SvJLgw%#)-QKOH}W=#Yz-CiPrw&~T za8}o6jaTvBZGd)XX28HZ-gk9+$z&kRe@jLC#d7nO$}Pb3GzuYjH7M@sgNP0JGLFZS zUTcgni(Y!pqQ^g+9ls|l`{mwRSw&)-Cl^+Z_wRX8+7w_GdL0M|FUKG!mG*Pw?_} z9P^HL!vdT#OxY@`A1`{nj; zH_2V^d;M1?oZ3^4Y|_SRnGm^quykP z>t6r}jPCkpZb^8K5rHyLJXwloq?0~uzwK-F1l#ZiLT1WJAU*fGT}KrNo)<@;aTfg6 zbTemk^M5*aKfB*h#3W?b^dal%j*;nSn;?4K*UEIfm+ zez}~iNe=G4n`89B`{>skbo&!FC?%r*t0&fpHFWeVZ|LynK1ZMRnV)TarFnz!ZWSnt z_x$giJ>BoEGUG2h%l4GrcSfYrKTibH&Y-&K2qT=MZUh~h((pv4#o^q(0VJZveY$_? zrgHIj6zVG{T zxij(#oqHqAsoHfI%H6R--$uwK&>^%$=k#$|g_<_UOh(~+l%u5|JjwGj=cK;lte zf~ZYw_?6M>ep!>`2q)!Yy$vx)vju59F4Cn_qQQq`o*XfpgU6?iti9Nv=L{xzggg*$ zu(U4L^9i;uyT|q1DkLK9G)hXuI^pU+$cZ$s`zohkeoRcr2v7|w>~s`{1HNsrhN{tD7SK*#G!NoqJ@T$=&G7MI(La?;9ua zxrUToJw7z<(Dhm!x1W(xju%$ZXy=4#xt|v6$cH{lOqhFCk*iN(mPH=kKkK0N|6W;B4>y~d=qMe7kVhUz&mWU&q-+Tu5p48pe z>}y&mc1uB*JFsVg=Z96;cJ)MU-VwCZVkaE^2jPgngHIi?tQ;IXo5&+z!33=gI{iARkaH? zJ?8DkVp68=(PO*g_S@5s<%(E9FI2K^;52yqVu_ag53-r4u~4GeXyvtrO}`F}urAruCKlZ8d**&UC$86^y4d)jE4F|xH-sx2 z?H_dybbzy@P~@KC%lX=0u^OOh`UlSYN2*)Kn|P8sRPF|9DbRCv+S-++m%*hehx>8g zZ+%PZn`6<35AyttF5nI1$JvIeZuYkpbZ_rK8pB%seUVf6P>8GXRh+HnBg6AetonmI z_SSu`tKD}Sy9?IOJ51BErda4s##_FX+wSSaM`2p&^{HbQ47nw;7^+#De2`=POKCv{ z-$Jw=fzr3Li=1)~8?c7;#s3j0@J0T5noHT z!?xGHzo<3|NfLZ}wZz`*?{@z!WokvcLV>Xt_T_0*i6)}wqw&Hkw%$4Z1I1C#(!%}D zt%F~4yQZKeClW%m96%;p9hLfVqg>evKQu@U@?8&zVk#}@`P6w+Qg9MzU%kIB_8lpy zgnN)@cr&fH997JgHpl{?fV~(llg^-|vN9K(nvYaW$01T(g)c}VsI7dX*9S9`ZCng^ zC%hV{rL0nXPyzbN8YfSw5@KK6!#HGlx%v2a4#(U+`9&tI)C>bD2e*vGXEn0*4#s%m z^M%LOUc5!D3ZFH(>&L?y-Fp?H;imY@3gcY;hWmr&%iG>GIgu);4fu57j=~yV$n| zKf*s=gm%u=IAcGDg1hV=nH8v#AE*t0P5%l7jmFKKOuK>pYga1`E){P>3y=GQ!|!Ft z9yiXH{)s8_h)@xph&xIf>SrdRFY{kiq>DPi!BkWH?ECsz`e>p=oqXZds5fG0T$MN3 zWeM6jz~)wO2HIAsRQ&}>l0{)VSMB!A~}LeiB^DE*YC{hrg7jJPxArVxXIr*h5`K{NJtd$#Jj$<(v|R?-%mjTi z=7h7?AIuH6?i8DU*BM)J8xrogF^{umWG|DiZp0-sGI?N_Z#qU`)4fDHRY^Y?ooE`b zJ+8YP-MJu%D35H?q1W_%Yw9#z`VDHe6MhA>&cf2W*0x4pcibJX5@VnWbz*E5Tk&=3j1=7r4vd#6%kjK7YG#S*~n#{b=DzwzS7UJul5Nb^(^T217{fi>QeKbF#?{5Y;pG#4&) zqsXaU$DR9vW-xOw4|%#vZ%hJdJBmt6uCtv7`LegO&Y1W4J1%)T_aLopcyy4`M&o9eBiJ zCMueZ;k7k5o+vH&U9!OxA20Uo78p=PG}zBQ=^PL18Q8NV=5pAA=V^LVI~0kyV~clG z?eqK`U?75${;68}5@IhRQc)xXX~s1(r{@_K#*XxtK}&PNt9t~UW_*r+Z&l$B8R6Yv zGFi5MP979SjVCQE^L44y3;ZF&+94XZTzlL)uFH?JsN(8r zlrhtBL*=o(NrJi#{}^-^MLWSbnS>JTr(tPvKa%pjX?9=Z46?x+e(Z?8nx1pDVyHgy zp6DJ6-hq-E=K!&8QsXiqNS>}wk9H{^)mY<~6u_wCIM?+z#(OG$1=1zci{`tHLlG_+6} zsgmQJKrGC=_$ldlY31`{eAc?k)p6vI>K?iaIR;b+^#UiKdiPVjrVw-{8j!F$jdEAV0 z{^Zf3%g5S9ihwoS*;I;&i3``11t>g$c@<L)$tSfJj0)^}ZA6_OB`hXq%}6~ZC?))|yI<82|5*ejnpE3N{8xqV~NXwO$xeUA;IH&5#oP92RF!0|T-bBcaDr1yfd{NtO6 zbnr#0rC@`;>^~f@L6tNvbKKHIG(dfuks`ig-!17mZ`G52m(+t`B%&e6cMeIa-C6a3 z7oCH^;1LGdkvx+^s0<^oh_2@rcc3hB8_5g)r~8F^kUD%ug`(3p&9F^nC1!hxnCO&r$6map0?5 zBmZ8H2bfR_hUSN2gSf&4rfaLR3N2q!r3ans+fLs6Mj;X?h%Jo^Sunnjo8j?K8 z>5t0RENbXE^B=H&3|JXn=U!}F3-4Zmoum;Qeme_Qo%T>}<}FeNa}T%BP{%pKO%Pua z#81R4%G^X^Sza4~g6FjmiPCw~btlRpuoGPO3IYGEbf*`(0OAMyRlI2w zSs~t=qcC(4r1%}AGti(48zN@F?pK2E!E)bYFk$IJ+1jfuYGzSY6j=|H+>7na(&aKj z`}#K*^US&}?~}(>e(m`q*N%OaIz|mADi8eg|7lrRUK?Gbkh?0aL*UM!yEP-$H^Gs_ zW_Y)fY^@TQ!0hDvm34JMjE{ zKabtjVA%l0vhxDDGr1u(qai2}wck=Aw9^5FsHjSTArhVWN9+W{#xzfKpGHp2u#y4>1 z-tGsF1^Ygir21!o@rG(RThWaO3hk?9sPaal9~YSoT7kpPHDch!&bxDW1wq81^~IX%B(vTweDBY*E$mcI(o>jix>J~4MXT(lC*ei1#L z7EBuGj|6Zy8|ys%Vq64Qt|XY5XI~{gO|~~w?`1cqkvm^P4|Nc#U7hIclz=tHUaa?2 z$L-iKz5{I9Hp816+2Ab_(bThLlo%m<$DlC5E~5IQI!E6lglgt)i++brvJz#=y}C=r zT&3B*cP*gMuv1Ev4ot@KTYo+Acvx{E+H4$%7OZ2ZwCSYIweP-m|}R=OT?{Qzv^=N*}6) zXuvY(5IwOj`Euvk!k%-W>N}$&^j_2*O`A(P`ePH^yvfu$?S;<(J?Bfk{9J0M`EiV% z4<*43x(nj^bRbC@>dm95PV~IvAcVp!G=;juD-F*{>(pvSoBC4G$=F~fp{X+KJn>h{ zj!dK8Ai#@B$U=;8mg_NjaadBpg*}ct5d)pSj>A?qFL!Kp9pRo4_cishc0L!;eQSa7 z-$bX=XfDT<;ZtvfsFb>+fEnid1Sss*wX#Y}X<@Ore2h$6+$AtPg59!`;oULeqxI)r zesYm))6Z=5QB={wj&O>b{pR}@$9_Cp$m5Ovg=UT-P7zdnn>wNy!{~QOJT)YR5uj`T z28HG8J1*?pd18qcVVDH5SimYo<(rS5U(L+RS}l<<aLdY&VZ%X7@-FkUuAqxR9mzq+-v@4lD z{}UD}W6d$h^hVHv}Qz&)MlQ2?ud-dHqiX&36%ls;K@c->fwSX zSV4(vGVk&J%rnD_Oy zrI9Y2*aBuDT>Q+i*QEDuwQSvb)1_`fEjU6~Ln>MBm50~<2X?tvjN1|5lA`Ejv#O0; z4i7}(P$QkUt|G)w;J>Q>FFwy+txIaVn{CBU=x;uYewQ%NU3RCJRAVZrE7b% zGltm}w?YbuMO(=~z`56@Zx;F<7V{m)dhU(eOzV`@fgOo*L)p}&)$vad%#QZ~abX4m zT(q#+v2!PlCz3xwxU$Hv(P^}keyqp`!?Zj(KUoURbKSgz1qKx9Dk|=KAN-o&tGbS} z zn-n?ovhtITF*@l)$KzodzB;joM2j-_JRFN}$sqG_pR~M%XQ>=_*re{qe5W1mzdl}Y z7;I1V>=p;a#TNpm53A#?FaItRK}|>MCi~dBx>I+OQx8xO!1n301TQ~|K$z2b=hz)w z&>JX_Bx|D>@+wh{`fkR>R)HO`?K`M0bU8j$By-Li2ac>HW_9`bOIyQk1wHBjb;+-mK!3Jzkd?B9AL#EPJIn2c#8Oh_U|K*8sNS=rShQT}p!~u4t<8 literal 0 HcmV?d00001 diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 6348fcb83..e2f9f8650 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -447,6 +447,34 @@ 필터 제거 + 컬렉션 + 컬렉션 만들기 + 등록된 컬렉션이 없어요 + 좋아요한 컬렉션이 없어요 + 서재에 등록한 작품이 없어요 + 100개까지 선택이 가능해요 + 다시 시도 + 컬렉션을 불러오지 못했어요. 다시 시도 + 컬렉션을 불러오지 못했어요 + 컬렉션 수정에 실패했어요 + 컬렉션 삭제에 실패했어요 + 좋아요 변경에 실패했어요 + 컬렉션 수정 + 컬렉션 삭제 + 컬렉션 메뉴 + 뒤로가기 + 등록 최신순 + 등록 오래된순 + 좋아요 (%1$d) + 공유하기 + 컬렉션 수정을 그만할까요? + 그만하기 + 계속 작성 + 해당 컬렉션을 삭제할까요? + 삭제한 컬렉션은 되돌릴 수 없어요 + 취소 + 작품 + 내 컬렉션 좋아요한 컬렉션 @@ -468,6 +496,7 @@ 서재에서 추가 작품 제목, 작가를 검색하세요 (%1$d/%2$d) + 컬렉션 생성에 실패했어요 보는 중 봤어요 하차 From 01cc59fae214ff320826444cd39f6d505b0cea05 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:51:40 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionCard.kt`: - 컬렉션의 이름, 설명, 작품 수, 공개 여부를 표시하는 카드 UI 구현 - 최근 추가된 작품의 커버 이미지들을 중첩하여 표시하는 로직 및 최대 글자 수 초과 시 말줄임(`ellipsize`) 처리 추가 - `CollectionConfirmDialog.kt`: 컬렉션 삭제 또는 생성 중단 시 확인을 위한 공통 다이얼로그 컴포넌트 구현 - `CollectionCreateButton.kt`: 버튼 내 텍스트를 스트링 리소스로 관리하도록 수정 - `CollectionEntry.kt`: - 컬렉션 개수 표시에 따른 하단 패딩 및 텍스트 포맷 로직 수정 - `Image`를 `Icon`으로 교체하고 틴트 컬러 적용 및 미리보기(`Preview`) 추가 --- .../collection/component/CollectionCard.kt | 119 ++++++++++++++++++ .../component/CollectionConfirmDialog.kt | 102 +++++++++++++++ .../component/CollectionCreateButton.kt | 3 +- .../collection/component/CollectionEntry.kt | 26 +++- 4 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCard.kt create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionConfirmDialog.kt diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCard.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCard.kt new file mode 100644 index 000000000..d84d1a44e --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCard.kt @@ -0,0 +1,119 @@ +package com.into.websoso.feature.collection.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.draw.clipToBounds +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.into.websoso.core.designsystem.component.NetworkImage +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.Primary20 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R +import com.into.websoso.domain.collection.model.Collection + +@Composable +internal fun CollectionCard( + collection: Collection, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Primary20) + .padding(horizontal = 17.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = collection.name.ellipsize(15), + color = Gray300, + style = WebsosoTheme.typography.title2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon(painterResource(R.drawable.ic_navigate_right), null, tint = Gray300, modifier = Modifier.size(24.dp)) + } + Text( + text = buildAnnotatedString { + collection.description?.takeIf(String::isNotBlank)?.let { append("${it.ellipsize(20)} · ") } + append(stringResource(R.string.collection_novels_label)) + append(" ") + withStyle(SpanStyle(color = Primary100)) { append(collection.novelCount.toString()) } + }, + color = Gray300, + style = WebsosoTheme.typography.label2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + BoxWithConstraints(Modifier.fillMaxWidth().height(108.dp).clipToBounds()) { + val coverWidth = 74.dp + val step = ((maxWidth - coverWidth) / 4).coerceAtLeast(0.dp) + repeat(5) { index -> + val novel = collection.recentNovels.getOrNull(index) + val coverModifier = Modifier + .offset(x = step * index) + .size(coverWidth, 108.dp) + .zIndex((5 - index).toFloat()) + .shadow(4.dp, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + if (novel == null) { + Image(painterResource(R.drawable.img_collection_empty_cover), null, coverModifier, contentScale = ContentScale.Crop) + } else { + NetworkImage( + imageUrl = novel.imageUrl, + contentDescription = novel.title, + contentScale = ContentScale.Crop, + alignment = Alignment.BottomCenter, + placeholder = painterResource(R.drawable.img_collection_empty_cover), + modifier = coverModifier, + ) + } + } + } + if (!collection.isPublic) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_lock), null, tint = Gray200, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.collection_create_private), color = Gray200, style = WebsosoTheme.typography.label2) + } + } + } +} + +internal fun String.ellipsize(limit: Int): String = if (length > limit) take(limit) + "…" else this diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionConfirmDialog.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionConfirmDialog.kt new file mode 100644 index 000000000..b033006c7 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionConfirmDialog.kt @@ -0,0 +1,102 @@ +package com.into.websoso.feature.collection.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Surface +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.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Black60 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.Secondary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R + +@Composable +internal fun CollectionConfirmDialog( + isDelete: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + modifier = Modifier.widthIn(max = 292.dp).dropShadow( + shape = RoundedCornerShape(12.dp), + shadow = Shadow(radius = 15.dp, color = Black60.copy(alpha = 0.1f), offset = DpOffset(0.dp, 2.dp)), + ), + shape = RoundedCornerShape(12.dp), + color = White, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Image(painterResource(R.drawable.ic_collection_warning), null, Modifier.size(46.dp)) + Text( + stringResource(if (isDelete) R.string.collection_delete_title else R.string.collection_discard_title), + style = WebsosoTheme.typography.title2, + color = Black, + textAlign = TextAlign.Center, + ) + if (isDelete) { + Text(stringResource(R.string.collection_delete_description), style = WebsosoTheme.typography.body3, color = Gray300) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + onClick = if (isDelete) onDismiss else onConfirm, + modifier = Modifier.weight(1f).height(40.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = Gray50, contentColor = Gray300), + ) { + Text( + stringResource(if (isDelete) R.string.collection_cancel else R.string.collection_discard), + style = WebsosoTheme.typography.body3, + ) + } + Button( + onClick = if (isDelete) onConfirm else onDismiss, + modifier = Modifier.weight(1f).height(40.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isDelete) Secondary100 else Primary100, + contentColor = White, + ), + ) { + Text( + stringResource(if (isDelete) R.string.collection_create_delete else R.string.collection_continue), + style = WebsosoTheme.typography.body3, + ) + } + } + } + } + } +} diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt index ace5b1610..515bc00fa 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -55,7 +56,7 @@ internal fun CollectionCreateButton( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "컬렉션 만들기", + text = stringResource(com.into.websoso.core.resource.R.string.collection_make), color = Primary100, style = WebsosoTheme.typography.title3, ) diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt index 1668bf1a7..73956bef8 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt @@ -1,17 +1,18 @@ package com.into.websoso.feature.collection.component -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -37,31 +38,44 @@ fun CollectionEntry( .background(White) .debouncedClickable(onClick = onClick) .padding( - horizontal = 20.dp, - vertical = 20.dp, + start = 20.dp, + top = 20.dp, + end = 20.dp, + bottom = if (collectionCount > 0) 16.dp else 20.dp, ), verticalAlignment = Alignment.CenterVertically, ) { Text( text = buildAnnotatedString { - append("컬렉션 ") + append(stringResource(R.string.collection_title)) + append(" ") withStyle(style = SpanStyle(color = Primary100)) { append(collectionCount.toString()) + if (collectionCount > 0) append(stringResource(R.string.collection_count_suffix)) } - append("개") + if (collectionCount == 0) append(stringResource(R.string.collection_count_suffix)) }, color = Gray300, style = WebsosoTheme.typography.title2, ) Spacer(modifier = Modifier.weight(1f)) - Image( + Icon( imageVector = ImageVector.vectorResource(R.drawable.btn_setting_right), contentDescription = null, + tint = Gray300, modifier = Modifier.size(24.dp), ) } } +@Preview(showBackground = true) +@Composable +private fun CollectionEntryWithCollectionsPreview() { + WebsosoTheme { + CollectionEntry(onClick = {}, collectionCount = 3) + } +} + @Preview(showBackground = true) @Composable private fun CollectionEntryPreview() { From 3cc14fba8d0bf16584f00daa30f3f63d823fdd9f Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:53:06 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionCreateUiState.kt`: 컬렉션 생성/수정 화면의 로딩, 에러, 초기 데이터 상태를 관리하는 UI 상태 클래스 추가 - `CollectionViewModel.kt`: 내 컬렉션, 좋아요한 컬렉션 및 특정 사용자의 컬렉션 리스트를 페이징 데이터로 제공하는 로직 구현 - `CollectionNovelSearchViewModel.kt`: - 컬렉션 생성 및 수정(`createCollection`) 기능 구현 - `SavedStateHandle`을 통한 초기 데이터 로드(`loadCollection`) 및 상태 관리 로직 추가 - 작품 선택 시 최대 개수(100개) 제한 및 중복 체크 로직 추가 - 결과 소비(`consumeCreateResult`) 함수 추가 - `CollectionLibraryNovelSelectionViewModel.kt`: 작품 선택 시 최대 개수 제한 로직 추가 및 초기 선택 상태 설정 시 중복 초기화 방지 - `CollectionDetailUiStateTest.kt`: `CollectionDetailUiState`의 초기 에러 표시 로직에 대한 단위 테스트 추가 --- ...ollectionLibraryNovelSelectionViewModel.kt | 5 +- .../CollectionNovelSearchViewModel.kt | 83 +++++++++++++++++++ .../feature/collection/CollectionViewModel.kt | 31 +++++++ .../model/CollectionCreateUiState.kt | 12 +++ .../collection/CollectionDetailUiStateTest.kt | 23 +++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionViewModel.kt create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionCreateUiState.kt create mode 100644 feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionDetailUiStateTest.kt diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt index 383799d36..f012b9ae9 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt @@ -26,6 +26,7 @@ internal class CollectionLibraryNovelSelectionViewModel libraryRepository: LibraryRepository, ) : ViewModel() { private val _selectedNovels = MutableStateFlow>(emptyList()) + private var isInitialized = false val selectedNovels: StateFlow> = _selectedNovels.asStateFlow() val novels: Flow> = @@ -39,12 +40,14 @@ internal class CollectionLibraryNovelSelectionViewModel if (selectedNovels.any { it.novelId == novel.novelId }) { selectedNovels.filterNot { it.novelId == novel.novelId } } else { - listOf(novel.toSelectedNovel()) + selectedNovels + if (selectedNovels.size >= 100) selectedNovels else listOf(novel.toSelectedNovel()) + selectedNovels } } } fun setSelectedNovels(novels: List) { + if (isInitialized) return + isInitialized = true _selectedNovels.value = novels } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt index 5719fe7a4..dafa55432 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt @@ -1,13 +1,18 @@ package com.into.websoso.feature.collection +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.into.websoso.data.novel.NovelSearchRepository import com.into.websoso.data.novel.model.NovelSearchEntity +import com.into.websoso.domain.collection.CollectionRepository +import com.into.websoso.domain.collection.model.SaveCollection +import com.into.websoso.feature.collection.model.CollectionCreateUiState import com.into.websoso.feature.collection.model.CollectionSelectedNovel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -22,7 +28,10 @@ internal class CollectionNovelSearchViewModel @Inject constructor( novelSearchRepository: NovelSearchRepository, + private val collectionRepository: CollectionRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val collectionId: Long = savedStateHandle["collectionId"] ?: 0L private val _submittedQuery = MutableStateFlow("") val submittedQuery: StateFlow = _submittedQuery.asStateFlow() @@ -32,6 +41,33 @@ internal class CollectionNovelSearchViewModel private val _representativeNovelId = MutableStateFlow(null) val representativeNovelId: StateFlow = _representativeNovelId.asStateFlow() + private val _createUiState = MutableStateFlow(CollectionCreateUiState()) + val createUiState: StateFlow = _createUiState.asStateFlow() + + init { + if (collectionId != 0L) loadCollection() + } + + fun loadCollection() { + if (_createUiState.value.isInitialLoading) return + _createUiState.value = _createUiState.value.copy(isInitialLoading = true, isLoadError = false) + viewModelScope.launch { + try { + val collection = collectionRepository.getCollection(collectionId) + check(collection.isMine) + _selectedNovels.value = collection.novels.map { + CollectionSelectedNovel(it.id, it.title, it.author, it.imageUrl) + } + _representativeNovelId.value = collection.representativeNovelId + _createUiState.value = CollectionCreateUiState(initialCollection = collection) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _createUiState.value = CollectionCreateUiState(isLoadError = true) + } + } + } + @OptIn(ExperimentalCoroutinesApi::class) val searchResults: Flow> = submittedQuery @@ -49,6 +85,7 @@ internal class CollectionNovelSearchViewModel fun addNovel(novel: NovelSearchEntity) { if (_selectedNovels.value.any { it.novelId == novel.novelId }) return + if (_selectedNovels.value.size >= 100) return _selectedNovels.value = listOf(novel.toSelectedNovel()) + _selectedNovels.value _representativeNovelId.value = novel.novelId @@ -64,6 +101,7 @@ internal class CollectionNovelSearchViewModel } fun updateSelectedNovels(novels: List) { + if (novels.size > 100 || novels.distinctBy { it.novelId }.size != novels.size) return val previousNovelIds = _selectedNovels.value.map(CollectionSelectedNovel::novelId) val novelIds = novels.map(CollectionSelectedNovel::novelId) val selectedNovelIds = novelIds.toSet() @@ -80,6 +118,51 @@ internal class CollectionNovelSearchViewModel _representativeNovelId.value = novelId } } + + fun createCollection( + name: String, + description: String, + isPrivate: Boolean, + ) { + if (_createUiState.value.isLoading) return + if (_createUiState.value.createdCollectionId != null) return + if (_createUiState.value.isInitialLoading || _createUiState.value.isLoadError) return + + val representativeNovelId = _representativeNovelId.value ?: return + val collection = runCatching { + SaveCollection( + name = name, + description = description, + isPublic = !isPrivate, + novelIds = _selectedNovels.value.map(CollectionSelectedNovel::novelId), + representativeNovelId = representativeNovelId, + ) + }.getOrElse { + _createUiState.value = _createUiState.value.copy(isError = true) + return + } + + _createUiState.value = _createUiState.value.copy(isLoading = true, isError = false) + viewModelScope.launch { + try { + val savedId = if (collectionId == 0L) { + collectionRepository.createCollection(collection) + } else { + collectionRepository.updateCollection(collectionId, collection) + collectionId + } + _createUiState.value = _createUiState.value.copy(isLoading = false, createdCollectionId = savedId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _createUiState.value = _createUiState.value.copy(isLoading = false, isError = true) + } + } + } + + fun consumeCreateResult() { + _createUiState.value = _createUiState.value.copy(createdCollectionId = null, isError = false) + } } private fun NovelSearchEntity.toSelectedNovel(): CollectionSelectedNovel = diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionViewModel.kt new file mode 100644 index 000000000..a5816ffff --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionViewModel.kt @@ -0,0 +1,31 @@ +package com.into.websoso.feature.collection + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.into.websoso.domain.collection.CollectionRepository +import com.into.websoso.domain.collection.model.Collection +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@HiltViewModel +internal class CollectionViewModel + @Inject + constructor( + private val repository: CollectionRepository, + ) : ViewModel() { + val myCollections = repository.getMyCollections().cachedIn(viewModelScope) + val likedCollections = repository.getLikedCollections().cachedIn(viewModelScope) + private val userCollections = mutableMapOf>>() + + fun collections(userId: Long?) = + if (userId == null) { + myCollections + } else { + userCollections.getOrPut(userId) { + repository.getUserCollections(userId).cachedIn(viewModelScope) + } + } + } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionCreateUiState.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionCreateUiState.kt new file mode 100644 index 000000000..bc02493d7 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionCreateUiState.kt @@ -0,0 +1,12 @@ +package com.into.websoso.feature.collection.model + +import com.into.websoso.domain.collection.model.CollectionDetail + +internal data class CollectionCreateUiState( + val isLoading: Boolean = false, + val createdCollectionId: Long? = null, + val isError: Boolean = false, + val initialCollection: CollectionDetail? = null, + val isInitialLoading: Boolean = false, + val isLoadError: Boolean = false, +) diff --git a/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionDetailUiStateTest.kt b/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionDetailUiStateTest.kt new file mode 100644 index 000000000..d26640a73 --- /dev/null +++ b/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionDetailUiStateTest.kt @@ -0,0 +1,23 @@ +package com.into.websoso.feature.collection + +import com.into.websoso.core.resource.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CollectionDetailUiStateTest { + @Test + fun `진입 직후와 응답 대기 중에는 오류 화면을 표시하지 않는다`() { + val initial = CollectionDetailUiState() + assertFalse(initial.showInitialError) + assertFalse(initial.copy(isLoading = true).showInitialError) + } + + @Test + fun `실제 요청 실패에만 오류를 표시하고 재시도 중에는 숨긴다`() { + val failed = CollectionDetailUiState(error = R.string.collection_load_failed) + assertTrue(failed.showInitialError) + assertFalse(failed.copy(isLoading = true).showInitialError) + assertFalse(failed.copy(isLoading = true, error = null).showInitialError) + } +} From d00681614607a4a9957bf1570e69fc89ebf1a1c1 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:54:12 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionDetailScreen.kt`: - 컬렉션 상세 정보(대표 이미지, 소유자, 제목, 설명, 좋아요 및 공개 여부)를 표시하는 헤더 영역 구현 - 작품 리스트를 3열 그리드 형태로 노출하고 정렬(최신순/오래된순) 기능 추가 - 스크롤 상태에 따라 변화하는 상단 앱바 구현 (타이틀 노출 및 배경색 변경) - 본인 컬렉션인 경우 수정/삭제 메뉴 팝업 및 삭제 확인 다이얼로그 연결 - 좋아요 토글, 작품 클릭, 풀투리프레시(PullToRefresh) 등 사용자 인터랙션 처리 - 데이터 로딩 상태 및 네트워크 에러 처리를 위한 UI 로직 추가 (CircularProgressIndicator, Snackbar, NetworkError) - `CollectionSortSheet` 컴포저블을 통한 정렬 기준 선택 바텀시트 구현 --- .../collection/CollectionDetailScreen.kt | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt new file mode 100644 index 000000000..d15481967 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt @@ -0,0 +1,468 @@ +package com.into.websoso.feature.collection + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.into.websoso.core.designsystem.component.NetworkImage +import com.into.websoso.core.designsystem.component.S3Image +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Black60 +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Gray80 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.Primary20 +import com.into.websoso.core.designsystem.theme.Primary30 +import com.into.websoso.core.designsystem.theme.Transparent +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionSortCriteria +import com.into.websoso.feature.collection.component.CollectionConfirmDialog +import com.into.websoso.feature.collection.component.CollectionNetworkError +import com.into.websoso.feature.collection.component.ellipsize +import kotlinx.coroutines.launch + +@Composable +internal fun CollectionDetailScreen( + onNavigateBack: () -> Unit, + onEdit: (Long) -> Unit, + onDeleted: () -> Unit, + onNovelClick: (Long) -> Unit, + viewModel: CollectionDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val gridState = rememberLazyGridState() + val isCollapsed by remember { derivedStateOf { gridState.firstVisibleItemIndex > 0 } } + var menuExpanded by remember { mutableStateOf(false) } + var showDelete by remember { mutableStateOf(false) } + var showSort by remember { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } + val snackbarScope = rememberCoroutineScope() + val context = LocalContext.current + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh() } + LaunchedEffect(state.isDeleted) { if (state.isDeleted) onDeleted() } + LaunchedEffect(state.error) { + state.error?.takeIf { state.collection != null }?.let { + viewModel.consumeError() + snackbarScope.launch { snackbar.showSnackbar(context.getString(it)) } + } + } + val collection = state.collection + Box(Modifier.fillMaxSize().background(White).statusBarsPadding()) { + if (collection == null) { + Column { + DetailAppBar("", true, false, onNavigateBack, {}) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (state.showInitialError) { + CollectionNetworkError(viewModel::refresh) + } else { + CircularProgressIndicator() + } + } + } + } else { + val novels = if (state.sort == CollectionSortCriteria.RECENT) collection.novels else collection.novels.asReversed() + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = 20.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + CollectionDetailHeader(collection, state.isBusy || state.isLoading, viewModel::toggleLike) + } + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.collection_create_added_novel_count, collection.novelCount), + color = Gray200, + style = WebsosoTheme.typography.body3, + ) + Row( + Modifier.clickable { showSort = true }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(painterResource(R.drawable.ic_library_sort), null, Modifier.size(16.dp), tint = Gray200) + Text(state.sort.label, color = Gray300, style = WebsosoTheme.typography.body3) + } + } + } + items(novels.chunked(3), key = { it.first().id }, span = { GridItemSpan(maxLineSpan) }) { row -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + row.forEach { novel -> + Column( + Modifier.weight(1f).clickable(role = Role.Button) { onNovelClick(novel.id) }, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + NetworkImage( + imageUrl = novel.imageUrl, + contentDescription = novel.title, + contentScale = ContentScale.Crop, + alignment = Alignment.BottomCenter, + placeholder = painterResource(R.drawable.img_collection_empty_cover), + modifier = Modifier.fillMaxWidth().height(160.dp).clip(RoundedCornerShape(8.dp)), + ) + Text( + novel.title, + color = Black, + style = WebsosoTheme.typography.body4, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + novel.author, + color = Gray200, + style = WebsosoTheme.typography.label2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + repeat(3 - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } + DetailAppBar( + title = if (isCollapsed) collection.name.ellipsize(18) else "", + isCollapsed = isCollapsed, + isMine = collection.isMine, + onNavigateBack = onNavigateBack, + onMenuClick = { menuExpanded = true }, + ) + if (menuExpanded && collection.isMine) { + Box(Modifier.align(Alignment.TopEnd).padding(end = 15.dp)) { + CollectionDetailMenu( + enabled = !state.isBusy && !state.isLoading, + onDismiss = { menuExpanded = false }, + onEdit = { + menuExpanded = false + onEdit(collection.id) + }, + onDelete = { + menuExpanded = false + showDelete = true + }, + ) + } + } + if (showDelete) { + CollectionConfirmDialog(true, { showDelete = false }) { + showDelete = false + viewModel.delete() + } + } + if (showSort) { + CollectionSortSheet(state.sort, { showSort = false }, viewModel::sort) + } + if (state.isBusy) CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter)) + } +} + +@Composable +private fun CollectionDetailMenu( + enabled: Boolean, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(0, with(LocalDensity.current) { 54.dp.roundToPx() }), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + val shape = RoundedCornerShape(14.dp) + Column( + Modifier + .width(122.dp) + .dropShadow(shape, Shadow(radius = 7.5.dp, color = Black60.copy(alpha = 0.11f), offset = DpOffset(0.dp, 2.dp))) + .clip(shape) + .background(White), + ) { + Text( + text = stringResource(R.string.collection_edit), + color = Black, + style = WebsosoTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, role = Role.Button, onClick = onEdit).padding(15.dp), + ) + HorizontalDivider(thickness = 0.7.dp, color = Gray50) + Text( + text = stringResource(R.string.collection_delete), + color = Black, + style = WebsosoTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Button, onClick = onDelete) + .padding(horizontal = 15.dp, vertical = 14.dp), + ) + } + } +} + +@Composable +private fun DetailAppBar( + title: String, + isCollapsed: Boolean, + isMine: Boolean, + onNavigateBack: () -> Unit, + onMenuClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .height(44.dp) + .background( + if (isCollapsed || + title.isNotEmpty() + ) { + White + } else { + Transparent + }, + ).padding(start = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onNavigateBack, modifier = Modifier.size(44.dp)) { + Icon( + painterResource(R.drawable.ic_navigate_left), + stringResource(R.string.collection_back), + tint = if (isCollapsed) Gray300 else White, + ) + } + Text( + title, + Modifier.weight(1f), + color = Black, + style = WebsosoTheme.typography.title2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isMine) { + IconButton(onMenuClick, modifier = Modifier.width(60.dp).height(44.dp)) { + Icon( + painterResource(R.drawable.ic_three_dots), + stringResource(R.string.collection_menu), + Modifier.size(20.dp).rotate(90f), + tint = if (isCollapsed) Gray300 else White, + ) + } + } else { + Spacer(Modifier.width(60.dp)) + } + } +} + +@Composable +private fun CollectionDetailHeader( + collection: CollectionDetail, + isBusy: Boolean, + onLike: () -> Unit, +) { + Box(Modifier.fillMaxWidth().heightIn(min = 328.dp)) { + NetworkImage( + imageUrl = collection.novels + .firstOrNull { it.id == collection.representativeNovelId } + ?.imageUrl + .orEmpty(), + contentScale = ContentScale.Crop, + alignment = Alignment.BottomCenter, + modifier = Modifier.matchParentSize(), + ) + Box( + Modifier.matchParentSize().background( + Brush.verticalGradient( + listOf(Black60.copy(alpha = 0.51f), Black60.copy(alpha = 0.85f)), + ), + ), + ) + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = 156.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + S3Image(imageUrl = collection.owner.avatarImageUrl, modifier = Modifier.size(32.dp).clip(RoundedCornerShape(10.dp))) + Text(collection.owner.nickname, color = White, style = WebsosoTheme.typography.body4) + } + Text(collection.name, color = White, style = WebsosoTheme.typography.headline1) + collection.description?.takeIf(String::isNotBlank)?.let { + Text(it, color = White, style = WebsosoTheme.typography.body3) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) { + Row( + Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(15.dp)) + .background(if (collection.isLiked) Primary30 else White) + .border(1.dp, Primary100, RoundedCornerShape(15.dp)) + .toggleable(value = collection.isLiked, enabled = !isBusy, role = Role.Checkbox) { onLike() }, + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(if (collection.isLiked) R.drawable.ic_thumb_up_on else R.drawable.ic_thumb_up), + null, + Modifier.size(20.dp), + tint = Primary100, + ) + Text( + stringResource(R.string.collection_like_count, collection.likeCount), + color = Primary100, + style = WebsosoTheme.typography.body4, + ) + } + Row( + Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(15.dp)) + .background(if (collection.isPublic) Primary100 else Gray80) + .clickable(enabled = false, role = Role.Button) {}, + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!collection.isPublic) Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = Gray200) + Text( + stringResource(if (collection.isPublic) R.string.collection_share else R.string.collection_create_private), + color = if (collection.isPublic) White else Gray200, + style = WebsosoTheme.typography.body4, + ) + } + } + } + } +} + +private val CollectionSortCriteria.label: String + @Composable get() = stringResource( + if (this == + CollectionSortCriteria.RECENT + ) { + R.string.collection_sort_recent + } else { + R.string.collection_sort_old + }, + ) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CollectionSortSheet( + selected: CollectionSortCriteria, + onDismiss: () -> Unit, + onSelected: (CollectionSortCriteria) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = White, + dragHandle = null, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + CollectionSortCriteria.entries.forEach { criteria -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (criteria == selected) Primary20 else White) + .clickable { + onSelected(criteria) + onDismiss() + }.padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(20.dp)) { + if (criteria == selected) Icon(painterResource(R.drawable.ic_library_sort_check), null, tint = Primary100) + } + Text(criteria.label, color = if (criteria == selected) Black else Gray200, style = WebsosoTheme.typography.body2) + Spacer(Modifier.size(20.dp)) + } + } + } + } +} From f8765ef56c82c4491903e424bae8d4feac600d6f Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:55:00 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0(CollectionPreview)=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EB=B7=B0?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionPreview.kt`: - 컬렉션 목록 및 대표 이미지를 보여주는 UI 컴포넌트 추가 - 최대 3개의 컬렉션을 노출하며, 이미지 중첩 효과(Stack)가 적용된 UI 구현 - `LifecycleEventEffect`를 사용하여 화면 진입 시 데이터 자동 갱신 - 로딩 및 에러 상태에 따른 UI 처리(CircularProgressIndicator, 재시도 버튼) 포함 - `CollectionPreviewViewModel.kt`: - 내 컬렉션 또는 타 사용자의 컬렉션 미리보기 데이터를 조회하는 비즈니스 로직 구현 - `page`, `isLoading`, `isError` 등 UI 상태 관리 - `refresh` 함수를 통해 데이터 로드 및 에러 핸들링 수행 --- .../feature/collection/CollectionPreview.kt | 126 ++++++++++++++++++ .../collection/CollectionPreviewViewModel.kt | 43 ++++++ 2 files changed, 169 insertions(+) create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreviewViewModel.kt diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt new file mode 100644 index 000000000..744aba0a0 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt @@ -0,0 +1,126 @@ +package com.into.websoso.feature.collection + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.into.websoso.core.designsystem.component.NetworkImage +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Gray80 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R +import com.into.websoso.feature.collection.component.CollectionEntry + +@Composable +fun CollectionPreview( + onListClick: () -> Unit, + onCollectionClick: (Long) -> Unit, + modifier: Modifier = Modifier, + userId: Long? = null, +) { + val viewModel: CollectionPreviewViewModel = hiltViewModel(key = "collection-preview-${userId ?: "me"}") + val page by viewModel.page.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val isError by viewModel.isError.collectAsStateWithLifecycle() + val context = LocalContext.current + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh(userId) } + Column(modifier.fillMaxWidth().background(White)) { + val current = page + if (current != null) { + CollectionEntry( + collectionCount = current.totalCount, + onClick = { + if (userId != null && current.totalCount == 0) { + Toast.makeText(context, R.string.collection_empty, Toast.LENGTH_SHORT).show() + } else { + onListClick() + } + }, + ) + if (current.collections.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + ) { + current.collections.take(3).forEach { collection -> + Column( + Modifier.width(88.dp).clickable { onCollectionClick(collection.id) }, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(Modifier.size(88.dp, 108.397.dp)) { + Box( + Modifier + .offset(x = 14.dp) + .size(73.907.dp, 108.397.dp) + .clip(RoundedCornerShape(6.569.dp)) + .background(Gray200), + ) + Box( + Modifier + .offset(x = 7.dp) + .size(73.907.dp, 108.397.dp) + .clip(RoundedCornerShape(6.569.dp)) + .background(Gray80), + ) + NetworkImage( + imageUrl = collection.representativeNovel.imageUrl, + contentDescription = collection.name, + contentScale = ContentScale.Crop, + alignment = Alignment.BottomCenter, + placeholder = painterResource(R.drawable.img_collection_empty_cover), + modifier = Modifier.size(73.907.dp, 108.397.dp).clip(RoundedCornerShape(6.569.dp)), + ) + } + Text( + collection.name, + color = Gray300, + style = WebsosoTheme.typography.body5, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } else if (isLoading) { + Box( + Modifier.fillMaxWidth().padding(20.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.size(24.dp)) } + } + if (isError) { + TextButton(onClick = { viewModel.refresh(userId) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.collection_preview_retry)) + } + } + } +} diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreviewViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreviewViewModel.kt new file mode 100644 index 000000000..5aa11743f --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreviewViewModel.kt @@ -0,0 +1,43 @@ +package com.into.websoso.feature.collection + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.into.websoso.domain.collection.CollectionRepository +import com.into.websoso.domain.collection.model.CollectionPage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class CollectionPreviewViewModel + @Inject + constructor( + private val repository: CollectionRepository, + ) : ViewModel() { + private val _page = MutableStateFlow(null) + val page = _page.asStateFlow() + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + private val _isError = MutableStateFlow(false) + val isError = _isError.asStateFlow() + + fun refresh(userId: Long?) { + if (_isLoading.value) return + _isLoading.value = true + _isError.value = false + viewModelScope.launch { + try { + _page.value = if (userId == null) repository.getMyCollectionPreview() else repository.getUserCollectionPreview(userId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _isError.value = true + } finally { + _isLoading.value = false + } + } + } + } From 84af4fc39569c8390d2af4e513de3ddf4498eb5a Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:55:42 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20data-collection=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `data/collection/build.gradle.kts`: - 신규 모듈 생성을 위한 빌드 스크립트 정의 및 네임스페이스(`data.collection`) 설정 - `core.network`, `data.account`, `domain.collection` 프로젝트 의존성 추가 - `Retrofit`, `Paging`, `Kotlin Serialization` 등 데이터 처리를 위한 외부 라이브러리 의존성 설정 --- data/collection/build.gradle.kts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 data/collection/build.gradle.kts diff --git a/data/collection/build.gradle.kts b/data/collection/build.gradle.kts new file mode 100644 index 000000000..93557bbfe --- /dev/null +++ b/data/collection/build.gradle.kts @@ -0,0 +1,20 @@ +import com.into.websoso.setNamespace + +plugins { + id("websoso.android.library") + alias(libs.plugins.kotlin.serialization) +} + +android { + setNamespace("data.collection") +} + +dependencies { + implementation(projects.core.network) + implementation(projects.data.account) + implementation(projects.domain.collection) + + implementation(libs.paging.runtime) + implementation(libs.retrofit) + implementation(libs.serialization.json) +} From 68ab96a006ec645b8057adcb54869d567b5a0cd9 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 00:56:24 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C,=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **컬렉션 메인 (`CollectionScreen.kt`):** - `Paging 3`를 적용하여 내 컬렉션 및 좋아요한 컬렉션 리스트 구현 - `PullToRefreshBox`를 통한 새로고침 및 `LazyColumn` 상태 관리 추가 - 컬렉션이 없는 경우 표시할 `CollectionEmpty` 컴포저블 추가 - **컬렉션 생성 및 수정 (`CollectionCreateScreen.kt`):** - 기존 생성 로직에 수정 기능을 통합하고 `CollectionCreateUiState` 연동 - 로딩, 에러 처리(스낵바), 뒤로가기 시 이탈 방지 다이얼로그 추가 - 컬렉션 이름 및 작품 선택 필수 여부에 따른 완료 버튼 활성화 로직 구현 - **검색 및 보관함 선택 (`CollectionNovelSearchScreen.kt`, `CollectionLibraryNovelSelectionScreen.kt`):** - 작품 선택 최대 개수를 100개로 제한하고 초과 시 토스트 메시지 노출 - 페이징 로드 에러 시 재시도(`retry`) 버튼 및 검색 결과 공백 UI 추가 - **네비게이션 (`CollectionNavHost.kt`, `CollectionActivity.kt`):** - 상세 페이지, 편집 페이지에 대한 라우트 추가 및 `collectionId` 파라미터 처리 - `Activity` 진입 시 `collectionId` 또는 `userId`를 전달받을 수 있도록 수정 - **기타:** - `CollectionNovelSection.kt` 내 미사용 주석 제거 및 UI 정렬 방식 유지 --- .../ui/collection/CollectionActivity.kt | 21 ++- .../collection/CollectionCreateScreen.kt | 170 +++++++++++++----- .../CollectionLibraryNovelSelectionScreen.kt | 29 ++- .../feature/collection/CollectionNavHost.kt | 136 ++++++++------ .../collection/CollectionNovelSearchScreen.kt | 19 +- .../feature/collection/CollectionScreen.kt | 129 ++++++++++--- .../component/CollectionNovelSection.kt | 1 - 7 files changed, 370 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt b/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt index 8bd68268d..0ee410a2d 100644 --- a/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.feature.collection.CollectionNavHost +import com.into.websoso.ui.novelDetail.NovelDetailActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -16,12 +17,28 @@ class CollectionActivity : ComponentActivity() { setContent { WebsosoTheme { - CollectionNavHost(onNavigateBack = ::finish) + CollectionNavHost( + onNavigateBack = ::finish, + onNovelClick = { startActivity(NovelDetailActivity.getIntent(this, it)) }, + initialCollectionId = intent.getLongExtra(COLLECTION_ID, 0L).takeIf { it > 0L }, + userId = intent.getLongExtra(USER_ID, 0L).takeIf { it > 0L }, + ) } } } companion object { - fun getIntent(context: Context): Intent = Intent(context, CollectionActivity::class.java) + private const val COLLECTION_ID = "COLLECTION_ID" + private const val USER_ID = "USER_ID" + + fun getIntent( + context: Context, + collectionId: Long? = null, + userId: Long? = null, + ): Intent = + Intent(context, CollectionActivity::class.java).apply { + collectionId?.let { putExtra(COLLECTION_ID, it) } + userId?.let { putExtra(USER_ID, it) } + } } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt index b5dd8fc04..6a218eefb 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt @@ -1,92 +1,162 @@ package com.into.websoso.feature.collection +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.string.collection_create_failed import com.into.websoso.feature.collection.component.CollectionAppBar +import com.into.websoso.feature.collection.component.CollectionConfirmDialog import com.into.websoso.feature.collection.component.CollectionDescriptionInput import com.into.websoso.feature.collection.component.CollectionNameInput import com.into.websoso.feature.collection.component.CollectionNovelSection import com.into.websoso.feature.collection.component.CollectionPrivacySetting +import com.into.websoso.feature.collection.model.CollectionCreateUiState import com.into.websoso.feature.collection.model.CollectionSelectedNovel +import kotlinx.coroutines.launch @Composable internal fun CollectionCreateScreen( selectedNovels: List, representativeNovelId: Long?, + createUiState: CollectionCreateUiState, onRepresentativeNovelClick: (Long) -> Unit, + onCreateClick: (name: String, description: String, isPrivate: Boolean) -> Unit, + onCreateResultConsumed: () -> Unit, + onCreated: (Long) -> Unit, onNavigateBack: () -> Unit, onNavigateToNovelSearch: () -> Unit, + onDiscard: () -> Unit = onNavigateBack, modifier: Modifier = Modifier, ) { - var isPrivate by rememberSaveable { mutableStateOf(false) } - var collectionName by rememberSaveable { mutableStateOf("") } - var collectionDescription by rememberSaveable { mutableStateOf("") } - - Column( - modifier = modifier - .fillMaxSize() - .background(White) - .statusBarsPadding(), + val initial = createUiState.initialCollection + var isPrivate by rememberSaveable { mutableStateOf(initial?.isPublic == false) } + var collectionName by rememberSaveable { mutableStateOf(initial?.name.orEmpty()) } + var collectionDescription by rememberSaveable { mutableStateOf(initial?.description.orEmpty()) } + var showDiscard by remember { mutableStateOf(false) } + val requestBack = { + if (!createUiState.isLoading) { + if (initial != null) showDiscard = true else onNavigateBack() + } + } + BackHandler { requestBack() } + val snackbarHostState = remember { SnackbarHostState() } + val snackbarScope = rememberCoroutineScope() + val createFailedMessage = if (initial == + null ) { - CollectionAppBar( - actionLabel = "완료", - onNavigateBack = onNavigateBack, - isActionEnabled = collectionName.isNotBlank() && selectedNovels.isNotEmpty(), - ) + stringResource(collection_create_failed) + } else { + stringResource(com.into.websoso.core.resource.R.string.collection_update_failed) + } + + LaunchedEffect(createUiState.createdCollectionId) { + createUiState.createdCollectionId?.let { collectionId -> + onCreateResultConsumed() + onCreated(collectionId) + } + } + LaunchedEffect(createUiState.isError) { + if (createUiState.isError) { + onCreateResultConsumed() + snackbarScope.launch { snackbarHostState.showSnackbar(createFailedMessage) } + } + } + + Box(modifier = modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), + .background(White) + .statusBarsPadding(), ) { - CollectionPrivacySetting( - isPrivate = isPrivate, - onPrivateChange = { isPrivate = it }, - ) - CollectionNameInput( - value = collectionName, - onValueChange = { collectionName = it }, - modifier = Modifier.padding( - start = 20.dp, - top = 20.dp, - end = 20.dp, - ), - ) - CollectionDescriptionInput( - value = collectionDescription, - onValueChange = { collectionDescription = it }, - modifier = Modifier.padding( - start = 20.dp, - top = 30.dp, - end = 20.dp, - ), - ) - CollectionNovelSection( - selectedNovels = selectedNovels, - representativeNovelId = representativeNovelId, - onRepresentativeNovelClick = onRepresentativeNovelClick, - onAddNovelClick = onNavigateToNovelSearch, - modifier = Modifier.padding( - start = 20.dp, - top = 30.dp, - end = 20.dp, - bottom = 20.dp, - ), + CollectionAppBar( + actionLabel = stringResource(com.into.websoso.core.resource.R.string.collection_create_complete), + onNavigateBack = requestBack, + onActionClick = { + onCreateClick(collectionName, collectionDescription, isPrivate) + }, + isActionEnabled = + collectionName.isNotBlank() && + selectedNovels.isNotEmpty() && + !createUiState.isLoading, ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + CollectionPrivacySetting( + isPrivate = isPrivate, + onPrivateChange = { if (!createUiState.isLoading) isPrivate = it }, + ) + CollectionNameInput( + value = collectionName, + onValueChange = { if (!createUiState.isLoading) collectionName = it }, + modifier = Modifier.padding( + start = 20.dp, + top = 20.dp, + end = 20.dp, + ), + ) + CollectionDescriptionInput( + value = collectionDescription, + onValueChange = { if (!createUiState.isLoading) collectionDescription = it }, + modifier = Modifier.padding( + start = 20.dp, + top = 30.dp, + end = 20.dp, + ), + ) + CollectionNovelSection( + selectedNovels = selectedNovels, + representativeNovelId = representativeNovelId, + onRepresentativeNovelClick = { if (!createUiState.isLoading) onRepresentativeNovelClick(it) }, + onAddNovelClick = { if (!createUiState.isLoading) onNavigateToNovelSearch() }, + modifier = Modifier.padding( + start = 20.dp, + top = 30.dp, + end = 20.dp, + bottom = 20.dp, + ), + ) + } + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding(), + ) + if (createUiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + if (showDiscard) { + CollectionConfirmDialog(false, { showDiscard = false }, onDiscard) } } } @@ -98,7 +168,11 @@ private fun CollectionCreateScreenPreview() { CollectionCreateScreen( selectedNovels = emptyList(), representativeNovelId = null, + createUiState = CollectionCreateUiState(), onRepresentativeNovelClick = {}, + onCreateClick = { _, _, _ -> }, + onCreateResultConsumed = {}, + onCreated = {}, onNavigateBack = {}, onNavigateToNovelSearch = {}, ) diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt index a1e334e53..374019f87 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt @@ -1,5 +1,6 @@ package com.into.websoso.feature.collection +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,11 +14,14 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,6 +51,7 @@ internal fun CollectionLibraryNovelSelectionRoute( ) { val novels = viewModel.novels.collectAsLazyPagingItems() val selectedNovels by viewModel.selectedNovels.collectAsStateWithLifecycle() + val context = LocalContext.current LaunchedEffect(initialSelectedNovels) { viewModel.setSelectedNovels(initialSelectedNovels) @@ -55,7 +60,13 @@ internal fun CollectionLibraryNovelSelectionRoute( CollectionLibraryNovelSelectionScreen( novels = novels, selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId }, - onNovelSelectionChange = viewModel::toggleNovelSelection, + onNovelSelectionChange = { novel -> + if (selectedNovels.size >= 100 && selectedNovels.none { it.novelId == novel.novelId }) { + Toast.makeText(context, com.into.websoso.core.resource.R.string.collection_selection_limit, Toast.LENGTH_SHORT).show() + } else { + viewModel.toggleNovelSelection(novel) + } + }, onAddClick = { onAddClick(selectedNovels) }, onNavigateBack = onNavigateBack, ) @@ -93,8 +104,7 @@ internal fun CollectionLibraryNovelSelectionScreen( CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - (novels.itemCount == 0 && novels.loadState.refresh is LoadState.Error) || - novels.loadState.append is LoadState.Error -> { + novels.itemCount == 0 && novels.loadState.refresh is LoadState.Error -> { CollectionNetworkError( onRetryClick = novels::retry, modifier = Modifier.align(Alignment.Center), @@ -102,6 +112,12 @@ internal fun CollectionLibraryNovelSelectionScreen( } else -> { + if (novels.itemCount == 0) { + CollectionEmpty( + stringResource(com.into.websoso.core.resource.R.string.collection_library_empty), + Modifier.align(Alignment.Center), + ) + } LazyVerticalGrid( columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize(), @@ -132,6 +148,13 @@ internal fun CollectionLibraryNovelSelectionScreen( } } } + if (novels.loadState.append is LoadState.Error) { + item(span = { GridItemSpan(maxLineSpan) }) { + TextButton(onClick = novels::retry, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(com.into.websoso.core.resource.R.string.collection_retry)) + } + } + } } } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt index 4654b363d..51a7d934c 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt @@ -1,87 +1,121 @@ package com.into.websoso.feature.collection +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material3.CircularProgressIndicator 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.into.websoso.feature.collection.component.CollectionAppBar +import com.into.websoso.feature.collection.component.CollectionNetworkError private const val COLLECTION_ROUTE = "collection" -private const val COLLECTION_CREATE_ROUTE = "collection/create" -private const val COLLECTION_NOVEL_SEARCH_ROUTE = "collection/create/novel-search" -private const val COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE = - "collection/create/novel-search/library" +private const val COLLECTION_DETAIL_ROUTE = "collection/detail/{collectionId}" +private const val COLLECTION_EDITOR_ROUTE = "collection/editor/{collectionId}" +private const val COLLECTION_SEARCH_ROUTE = "collection/editor/{collectionId}/search" +private const val COLLECTION_LIBRARY_ROUTE = "collection/editor/{collectionId}/library" @Composable fun CollectionNavHost( onNavigateBack: () -> Unit, + onNovelClick: (Long) -> Unit, modifier: Modifier = Modifier, + initialCollectionId: Long? = null, + userId: Long? = null, ) { val navController = rememberNavController() + val arguments = listOf(navArgument("collectionId") { type = NavType.LongType }) + val start = if (initialCollectionId != null) "collection/detail/$initialCollectionId" else COLLECTION_ROUTE + val returnToMyList: () -> Unit = { + navController.navigate(COLLECTION_ROUTE) { + popUpTo(navController.graph.id) { inclusive = false } + launchSingleTop = true + } + navController.currentBackStackEntry?.savedStateHandle?.set("showMyCollections", 1) + } - NavHost( - navController = navController, - startDestination = COLLECTION_ROUTE, - modifier = modifier, - ) { - composable(route = COLLECTION_ROUTE) { + NavHost(navController, startDestination = start, modifier = modifier.navigationBarsPadding().imePadding()) { + composable(COLLECTION_ROUTE) { entry -> + val showMy by entry.savedStateHandle.getStateFlow("showMyCollections", 0).collectAsStateWithLifecycle() CollectionScreen( onNavigateBack = onNavigateBack, - onNavigateToCreate = { - navController.navigate(COLLECTION_CREATE_ROUTE) - }, + onNavigateToCreate = { navController.navigate("collection/editor/0") }, + onCollectionClick = { navController.navigate("collection/detail/$it") }, + userId = if (showMy > 0) null else userId, + showMyCollections = showMy, ) } - composable(route = COLLECTION_CREATE_ROUTE) { backStackEntry -> - val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(backStackEntry) - val selectedNovels by novelSearchViewModel.selectedNovels.collectAsStateWithLifecycle() - val representativeNovelId by - novelSearchViewModel.representativeNovelId.collectAsStateWithLifecycle() - - CollectionCreateScreen( - selectedNovels = selectedNovels, - representativeNovelId = representativeNovelId, - onRepresentativeNovelClick = novelSearchViewModel::updateRepresentativeNovel, - onNavigateBack = navController::popBackStack, - onNavigateToNovelSearch = { - navController.navigate(COLLECTION_NOVEL_SEARCH_ROUTE) - }, + composable(COLLECTION_DETAIL_ROUTE, arguments) { + CollectionDetailScreen( + onNavigateBack = { if (!navController.popBackStack()) onNavigateBack() }, + onEdit = { navController.navigate("collection/editor/$it") }, + onDeleted = returnToMyList, + onNovelClick = onNovelClick, ) } - composable(route = COLLECTION_NOVEL_SEARCH_ROUTE) { backStackEntry -> - val createBackStackEntry = remember(backStackEntry) { - navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) + composable(COLLECTION_EDITOR_ROUTE, arguments) { entry -> + val viewModel: CollectionNovelSearchViewModel = hiltViewModel(entry) + val selected by viewModel.selectedNovels.collectAsStateWithLifecycle() + val representative by viewModel.representativeNovelId.collectAsStateWithLifecycle() + val state by viewModel.createUiState.collectAsStateWithLifecycle() + val id = entry.arguments?.getLong("collectionId") ?: 0L + if (state.isInitialLoading || state.isLoadError || (id != 0L && state.initialCollection == null)) { + Box(Modifier.fillMaxSize()) { + CollectionAppBar(onNavigateBack = { navController.popBackStack() }) + if (state.isLoadError) { + CollectionNetworkError(viewModel::loadCollection, Modifier.align(Alignment.Center)) + } else { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + } else { + CollectionCreateScreen( + selectedNovels = selected, + representativeNovelId = representative, + createUiState = state, + onRepresentativeNovelClick = viewModel::updateRepresentativeNovel, + onCreateClick = viewModel::createCollection, + onCreateResultConsumed = viewModel::consumeCreateResult, + onCreated = { if (id == 0L) returnToMyList() else navController.popBackStack() }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToNovelSearch = { navController.navigate("collection/editor/$id/search") }, + onDiscard = returnToMyList, + ) } - val novelSearchViewModel: CollectionNovelSearchViewModel = - hiltViewModel(createBackStackEntry) - + } + composable(COLLECTION_SEARCH_ROUTE, arguments) { entry -> + val id = entry.arguments?.getLong("collectionId") ?: 0L + val editorEntry = remember(entry) { navController.getBackStackEntry("collection/editor/$id") } + val viewModel: CollectionNovelSearchViewModel = hiltViewModel(editorEntry) CollectionNovelSearchRoute( - viewModel = novelSearchViewModel, - onNavigateBack = navController::popBackStack, - onNavigateToLibraryNovelSelection = { - navController.navigate(COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) - }, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToLibraryNovelSelection = { navController.navigate("collection/editor/$id/library") }, ) } - composable(route = COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) { backStackEntry -> - val createBackStackEntry = remember(backStackEntry) { - navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) - } - val novelSearchViewModel: CollectionNovelSearchViewModel = - hiltViewModel(createBackStackEntry) - val selectedNovels by novelSearchViewModel.selectedNovels.collectAsStateWithLifecycle() - + composable(COLLECTION_LIBRARY_ROUTE, arguments) { entry -> + val id = entry.arguments?.getLong("collectionId") ?: 0L + val editorEntry = remember(entry) { navController.getBackStackEntry("collection/editor/$id") } + val viewModel: CollectionNovelSearchViewModel = hiltViewModel(editorEntry) + val selected by viewModel.selectedNovels.collectAsStateWithLifecycle() CollectionLibraryNovelSelectionRoute( - initialSelectedNovels = selectedNovels, - onAddClick = { selectedNovels -> - novelSearchViewModel.updateSelectedNovels(selectedNovels) - navController.popBackStack() + initialSelectedNovels = selected, + onAddClick = { + viewModel.updateSelectedNovels(it) + navController.popBackStack("collection/editor/$id", inclusive = false) }, - onNavigateBack = navController::popBackStack, + onNavigateBack = { navController.popBackStack() }, ) } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt index eb8c0faab..371d6f08e 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt @@ -1,5 +1,6 @@ package com.into.websoso.feature.collection +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -18,6 +19,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource @@ -73,6 +76,7 @@ internal fun CollectionNovelSearchRoute( val selectedNovels by viewModel.selectedNovels.collectAsStateWithLifecycle() val submittedQuery by viewModel.submittedQuery.collectAsStateWithLifecycle() val searchResults = viewModel.searchResults.collectAsLazyPagingItems() + val context = LocalContext.current CollectionNovelSearchScreen( searchResults = searchResults, @@ -85,7 +89,13 @@ internal fun CollectionNovelSearchRoute( viewModel.search(query) } }, - onAddNovel = viewModel::addNovel, + onAddNovel = { novel -> + if (selectedNovels.size >= 100) { + Toast.makeText(context, com.into.websoso.core.resource.R.string.collection_selection_limit, Toast.LENGTH_SHORT).show() + } else { + viewModel.addNovel(novel) + } + }, onDeleteNovel = viewModel::removeNovel, onNavigateBack = onNavigateBack, onNavigateToLibraryNovelSelection = onNavigateToLibraryNovelSelection, @@ -232,6 +242,13 @@ internal fun CollectionNovelSearchScreen( } } } + if (searchResults.loadState.append is LoadState.Error) { + item { + TextButton(onClick = searchResults::retry, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(com.into.websoso.core.resource.R.string.collection_retry)) + } + } + } } } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt index 23c2cecd2..4e83cc8ab 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt @@ -1,22 +1,46 @@ package com.into.websoso.feature.collection +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement 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.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R import com.into.websoso.feature.collection.component.CollectionAppBar +import com.into.websoso.feature.collection.component.CollectionCard import com.into.websoso.feature.collection.component.CollectionCreateButton +import com.into.websoso.feature.collection.component.CollectionNetworkError import com.into.websoso.feature.collection.component.CollectionTabRow import com.into.websoso.feature.collection.model.CollectionTab @@ -24,41 +48,88 @@ import com.into.websoso.feature.collection.model.CollectionTab fun CollectionScreen( onNavigateBack: () -> Unit, onNavigateToCreate: () -> Unit, + onCollectionClick: (Long) -> Unit, modifier: Modifier = Modifier, + userId: Long? = null, + showMyCollections: Int = 0, ) { + val viewModel: CollectionViewModel = hiltViewModel() var selectedTab by rememberSaveable { mutableStateOf(CollectionTab.MY_COLLECTION) } + LaunchedEffect(showMyCollections) { + if (showMyCollections > 0) selectedTab = CollectionTab.MY_COLLECTION + } + val myCollections = viewModel.collections(userId).collectAsLazyPagingItems() + val likedCollections = viewModel.likedCollections.collectAsLazyPagingItems() + val collections = if (selectedTab == CollectionTab.MY_COLLECTION) myCollections else likedCollections + val myListState = rememberLazyListState() + val likedListState = rememberLazyListState() - Column( - modifier = modifier - .fillMaxSize() - .background(White) - .statusBarsPadding(), - ) { - CollectionAppBar( - title = "컬렉션", - onNavigateBack = onNavigateBack, - ) - CollectionTabRow( - selectedTab = selectedTab, - onTabSelected = { selectedTab = it }, - ) - CollectionCreateButton( - onClick = onNavigateToCreate, - modifier = Modifier.padding( - horizontal = 20.dp, - vertical = 16.dp, - ), - ) + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + myCollections.refresh() + likedCollections.refresh() + } + + Column(modifier.fillMaxSize().background(White).statusBarsPadding()) { + CollectionAppBar(title = stringResource(R.string.collection_title), onNavigateBack = onNavigateBack) + if (userId == null) { + CollectionTabRow(selectedTab = selectedTab, onTabSelected = { selectedTab = it }) + } + PullToRefreshBox( + isRefreshing = collections.loadState.refresh is LoadState.Loading && collections.itemCount > 0, + onRefresh = collections::refresh, + modifier = Modifier.weight(1f), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = if (selectedTab == CollectionTab.MY_COLLECTION) myListState else likedListState, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (userId == null && selectedTab == CollectionTab.MY_COLLECTION) { + item { CollectionCreateButton(onClick = onNavigateToCreate) } + } + items(count = collections.itemCount, key = collections.itemKey { it.id }) { index -> + collections[index]?.let { collection -> + CollectionCard(collection, onClick = { onCollectionClick(collection.id) }) + } + } + if (collections.loadState.append is LoadState.Loading) { + item { CircularProgressIndicator() } + } + if (collections.itemCount > 0 && + (collections.loadState.append is LoadState.Error || collections.loadState.refresh is LoadState.Error) + ) { + item { TextButton(onClick = collections::retry) { Text(stringResource(R.string.collection_retry)) } } + } + } + if (collections.itemCount == 0) { + when (collections.loadState.refresh) { + is LoadState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center)) + is LoadState.Error -> CollectionNetworkError(collections::retry, Modifier.align(Alignment.Center)) + is LoadState.NotLoading -> if (selectedTab == CollectionTab.LIKED_COLLECTION || userId != null) { + CollectionEmpty( + message = stringResource(if (userId == null) R.string.collection_liked_empty else R.string.collection_empty), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } } } -@Preview(showBackground = true) @Composable -private fun CollectionScreenPreview() { - WebsosoTheme { - CollectionScreen( - onNavigateBack = {}, - onNavigateToCreate = {}, - ) +internal fun CollectionEmpty( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image(painterResource(R.drawable.ic_collection_empty), null, Modifier.size(39.dp, 48.dp)) + Text(message, color = Gray200, style = WebsosoTheme.typography.body1) } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt index 48d44433e..a6257f6b1 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt @@ -207,7 +207,6 @@ private fun CollectionNovelItem( imageUrl = novel.imageUrl, contentDescription = novel.title, contentScale = ContentScale.Crop, - // TODO: 기획·디자인 확인 후 Alignment.Center 적용 여부 재검토 alignment = Alignment.BottomCenter, modifier = Modifier.fillMaxSize(), ) From c7b718b0561bf2b26d995fb98b921e6d40e9865f Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 01:02:18 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20ViewModel=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionDetailViewModel.kt`: - 컬렉션 상세 데이터, 로딩, 삭제 상태 및 에러 등을 관리하는 `CollectionDetailUiState` 정의 - 컬렉션 상세 정보 조회(`refresh`) 및 정렬 기준(`sort`) 변경 기능 구현 - 좋아요 토글(`toggleLike`) 및 컬렉션 삭제(`delete`) 비즈니스 로직 구현 - `SavedStateHandle`을 사용하여 `collectionId` 및 정렬 상태(`sort`)를 관리하고 초기화하도록 구현 --- .../collection/CollectionDetailViewModel.kt | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt new file mode 100644 index 000000000..c727e1a9a --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt @@ -0,0 +1,109 @@ +package com.into.websoso.feature.collection + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.into.websoso.core.resource.R +import com.into.websoso.domain.collection.CollectionRepository +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionSortCriteria +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal data class CollectionDetailUiState( + val collection: CollectionDetail? = null, + val isLoading: Boolean = false, + val isBusy: Boolean = false, + val isDeleted: Boolean = false, + val error: Int? = null, + val sort: CollectionSortCriteria = CollectionSortCriteria.RECENT, +) { + val showInitialError: Boolean + get() = collection == null && !isLoading && error != null +} + +@HiltViewModel +internal class CollectionDetailViewModel + @Inject + constructor( + private val repository: CollectionRepository, + private val savedStateHandle: SavedStateHandle, + ) : ViewModel() { + private val collectionId: Long = checkNotNull(savedStateHandle["collectionId"]) + private val _uiState = MutableStateFlow( + CollectionDetailUiState( + sort = CollectionSortCriteria.valueOf(savedStateHandle["sort"] ?: "RECENT"), + ), + ) + val uiState = _uiState.asStateFlow() + + fun refresh() { + if (_uiState.value.isLoading || _uiState.value.isBusy || _uiState.value.isDeleted) return + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + try { + val collection = repository.getCollection(collectionId) + _uiState.update { it.copy(collection = collection, isLoading = false) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _uiState.update { it.copy(isLoading = false, error = R.string.collection_load_failed) } + } + } + } + + fun sort(criteria: CollectionSortCriteria) { + savedStateHandle["sort"] = criteria.name + _uiState.update { it.copy(sort = criteria) } + } + + fun toggleLike() { + val current = _uiState.value + val collection = current.collection ?: return + if (current.isBusy || current.isLoading || current.isDeleted) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + try { + repository.updateLike(collectionId, !collection.isLiked) + _uiState.update { + it.copy( + isBusy = false, + collection = collection.copy( + isLiked = !collection.isLiked, + likeCount = (collection.likeCount + if (collection.isLiked) -1 else 1).coerceAtLeast(0), + ), + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _uiState.update { it.copy(isBusy = false, error = R.string.collection_like_failed) } + } + } + } + + fun delete() { + val state = _uiState.value + if (state.collection?.isMine != true || state.isBusy || state.isLoading || state.isDeleted) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + try { + repository.deleteCollection(collectionId) + _uiState.update { it.copy(isBusy = false, isDeleted = true) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _uiState.update { it.copy(isBusy = false, error = R.string.collection_delete_failed) } + } + } + } + + fun consumeError() { + _uiState.update { it.copy(error = null) } + } + } From de1ca58afb97f89f751f41e7d18e8f5945cbdadf Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 01:02:31 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EC=9E=AC=20=EB=82=B4=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?UI=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MyPageFragment.kt`: `CollectionEntry`를 `CollectionPreview`로 교체하고, 컬렉션 개별 클릭 이벤트 핸들러(`onCollectionClick`) 추가 - `OtherUserLibraryFragment.kt`: - 타인 서재 화면에 `CollectionPreview` 컴포저블 적용 - 서재 정보가 없을 경우 선호도 영역을 숨기는 로직 추가 - `fragment_other_user_library.xml`: - 컬렉션 미리보기를 위한 `ComposeView` 및 `Barrier` 추가 - `CollectionPreview` 위치 확보를 위해 기존 레이아웃(`cl_other_user_library_storage` 등)의 제약 조건 및 배치 순서 조정 --- .../websoso/ui/main/myPage/MyPageFragment.kt | 7 +- .../OtherUserLibraryFragment.kt | 25 +++ .../layout/fragment_other_user_library.xml | 178 ++++++++++-------- 3 files changed, 126 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/myPage/MyPageFragment.kt b/app/src/main/java/com/into/websoso/ui/main/myPage/MyPageFragment.kt index 8ceba728c..4f00c4da3 100644 --- a/app/src/main/java/com/into/websoso/ui/main/myPage/MyPageFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/myPage/MyPageFragment.kt @@ -35,7 +35,7 @@ import com.into.websoso.core.resource.R.string.my_library_attractive_point_fixed import com.into.websoso.data.model.GenrePreferenceEntity import com.into.websoso.data.model.NovelPreferenceEntity import com.into.websoso.databinding.FragmentMyPageBinding -import com.into.websoso.feature.collection.component.CollectionEntry +import com.into.websoso.feature.collection.CollectionPreview import com.into.websoso.ui.collection.CollectionActivity import com.into.websoso.ui.main.MainViewModel import com.into.websoso.ui.main.myPage.adapter.RestGenrePreferenceAdapter @@ -101,7 +101,10 @@ class MyPageFragment : BaseFragment(fragment_my_page) { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { WebsosoTheme { - CollectionEntry(onClick = ::navigateToCollection) + CollectionPreview( + onListClick = ::navigateToCollection, + onCollectionClick = { startActivity(CollectionActivity.getIntent(requireContext(), collectionId = it)) }, + ) } } } diff --git a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt index 6d38f6370..9c1ce7219 100644 --- a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt @@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE import android.text.style.ForegroundColorSpan import android.view.View +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels @@ -22,10 +23,13 @@ import com.into.websoso.core.common.ui.custom.WebsosoChip import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.getS3ImageUrl import com.into.websoso.core.common.util.setListViewHeightBasedOnChildren +import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R.string.my_library_attractive_point_fixed_text import com.into.websoso.data.model.GenrePreferenceEntity import com.into.websoso.data.model.NovelPreferenceEntity import com.into.websoso.databinding.FragmentOtherUserLibraryBinding +import com.into.websoso.feature.collection.CollectionPreview +import com.into.websoso.ui.collection.CollectionActivity import com.into.websoso.ui.otherUserPage.otherUserLibrary.adapter.RestGenrePreferenceAdapter import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -52,6 +56,22 @@ class OtherUserLibraryFragment : BaseFragment(f setupRestGenrePreferenceAdapter() setupObserve() onStorageButtonClick() + binding.cvOtherUserCollection.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + WebsosoTheme { + CollectionPreview( + userId = userId, + onListClick = { startActivity(CollectionActivity.getIntent(requireContext(), userId = userId)) }, + onCollectionClick = { + startActivity( + CollectionActivity.getIntent(requireContext(), collectionId = it, userId = userId), + ) + }, + ) + } + } + } } private fun bindViewModel() { @@ -101,6 +121,11 @@ class OtherUserLibraryFragment : BaseFragment(f } } + if (otherUserLibraryViewModel.hasNoPreferences()) { + binding.clOtherUserLibraryNovelPreference.isVisible = false + binding.clOtherUserLibraryUnknownNovelPreference.isVisible = false + } + when (otherUserLibraryViewModel.hasAttractivePoints()) { true -> { binding.clOtherUserLibraryAttractivePoints.visibility = View.VISIBLE diff --git a/app/src/main/res/layout/fragment_other_user_library.xml b/app/src/main/res/layout/fragment_other_user_library.xml index 620953b81..6e0136a59 100644 --- a/app/src/main/res/layout/fragment_other_user_library.xml +++ b/app/src/main/res/layout/fragment_other_user_library.xml @@ -15,12 +15,26 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + + + + + app:layout_constraintTop_toBottomOf="@id/cv_other_user_collection"> + app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toTopOf="parent"> + + + + + + + android:layout_height="0dp" + android:layout_marginTop="12dp" + android:background="@drawable/bg_my_library_gray50_radius_12dp" + android:paddingVertical="20dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/tv_other_user_library_novel_preference_title"> - - - - - - - - + app:layout_constraintTop_toTopOf="parent" + tools:text="현캐릭터,소재가 매력적인 작품" /> - + android:layout_marginTop="16dp" + app:layout_constraintTop_toBottomOf="@id/cl_other_user_library_attractive_points" /> - + - + - + - + + + From 44bfc0a63de2d7b2a2d7279299fd327cba1fc3eb Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 01:06:18 +0900 Subject: [PATCH 12/19] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EA=B0=80=EB=8F=85?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OtherUserLibraryFragment.kt | 25 ++++++++++++++++--- .../feature/collection/CollectionScreen.kt | 15 ++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt index 9c1ce7219..235f8e6d1 100644 --- a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt @@ -62,10 +62,21 @@ class OtherUserLibraryFragment : BaseFragment(f WebsosoTheme { CollectionPreview( userId = userId, - onListClick = { startActivity(CollectionActivity.getIntent(requireContext(), userId = userId)) }, + onListClick = { + startActivity( + CollectionActivity.getIntent( + requireContext(), + userId = userId, + ), + ) + }, onCollectionClick = { startActivity( - CollectionActivity.getIntent(requireContext(), collectionId = it, userId = userId), + CollectionActivity.getIntent( + requireContext(), + collectionId = it, + userId = userId, + ), ) }, ) @@ -90,8 +101,14 @@ class OtherUserLibraryFragment : BaseFragment(f private fun setupObserve() { otherUserLibraryViewModel.uiState.observe(viewLifecycleOwner) { uiState -> when { - uiState.isLoading -> binding.wllOtherUserLibrary.setWebsosoLoadingVisibility(true) - uiState.error -> binding.wllOtherUserLibrary.setLoadingLayoutVisibility(false) + uiState.isLoading -> { + binding.wllOtherUserLibrary.setWebsosoLoadingVisibility(true) + } + + uiState.error -> { + binding.wllOtherUserLibrary.setLoadingLayoutVisibility(false) + } + !uiState.isLoading -> { binding.wllOtherUserLibrary.setWebsosoLoadingVisibility(false) } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt index 4e83cc8ab..488cb7656 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt @@ -60,7 +60,8 @@ fun CollectionScreen( } val myCollections = viewModel.collections(userId).collectAsLazyPagingItems() val likedCollections = viewModel.likedCollections.collectAsLazyPagingItems() - val collections = if (selectedTab == CollectionTab.MY_COLLECTION) myCollections else likedCollections + val collections = + if (selectedTab == CollectionTab.MY_COLLECTION) myCollections else likedCollections val myListState = rememberLazyListState() val likedListState = rememberLazyListState() @@ -70,7 +71,10 @@ fun CollectionScreen( } Column(modifier.fillMaxSize().background(White).statusBarsPadding()) { - CollectionAppBar(title = stringResource(R.string.collection_title), onNavigateBack = onNavigateBack) + CollectionAppBar( + title = stringResource(R.string.collection_title), + onNavigateBack = onNavigateBack, + ) if (userId == null) { CollectionTabRow(selectedTab = selectedTab, onTabSelected = { selectedTab = it }) } @@ -106,7 +110,12 @@ fun CollectionScreen( if (collections.itemCount == 0) { when (collections.loadState.refresh) { is LoadState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center)) - is LoadState.Error -> CollectionNetworkError(collections::retry, Modifier.align(Alignment.Center)) + + is LoadState.Error -> CollectionNetworkError( + collections::retry, + Modifier.align(Alignment.Center), + ) + is LoadState.NotLoading -> if (selectedTab == CollectionTab.LIKED_COLLECTION || userId != null) { CollectionEmpty( message = stringResource(if (userId == null) R.string.collection_liked_empty else R.string.collection_empty), From b8eade618dcb22e5bc0caed1889a49374a7adce1 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 01:51:51 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EC=9E=AC=20=EB=82=B4=20=EC=BB=AC=EB=A0=89=EC=85=98=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20UI=20=EB=B0=8F=20=EB=B9=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionPreview.kt`: - 타인 서재 여부에 따라 패딩 값 조정 - 빈 컬렉션 클릭 시 처리를 위한 `onEmptyClick` 콜백 추가 및 기존 `Toast` 로직 대체 - `CollectionEntry.kt`: - `showCount` 파라미터를 추가하여 컬렉션 개수 노출 여부 제어 - `showCount`가 false일 경우의 텍스트 스타일(title1, Black) 및 높이(44.dp) 정의 - `OtherUserLibraryFragment.kt`: - `onEmptyClick` 발생 시 `showWebsosoToast`를 사용하여 안내 메시지 출력 - `fragment_other_user_library.xml`: - 컬렉션 프리뷰(`cv_other_user_collection`)의 위치를 상단으로 변경하고 구분선(`divider`) 추가 및 제약 조건 재설정 - `strings.xml`: 타인 서재용 컬렉션 빈 상태 메시지(`collection_other_user_empty`) 추가 --- .../OtherUserLibraryFragment.kt | 10 +++++ .../layout/fragment_other_user_library.xml | 27 +++++++++--- core/resource/src/main/res/values/strings.xml | 1 + .../feature/collection/CollectionPreview.kt | 13 +++--- .../collection/component/CollectionEntry.kt | 41 ++++++++++++------- 5 files changed, 66 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt index 235f8e6d1..1ff02e2c4 100644 --- a/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/otherUserPage/otherUserLibrary/OtherUserLibraryFragment.kt @@ -23,7 +23,10 @@ import com.into.websoso.core.common.ui.custom.WebsosoChip import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.getS3ImageUrl import com.into.websoso.core.common.util.setListViewHeightBasedOnChildren +import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_alert +import com.into.websoso.core.resource.R.string.collection_other_user_empty import com.into.websoso.core.resource.R.string.my_library_attractive_point_fixed_text import com.into.websoso.data.model.GenrePreferenceEntity import com.into.websoso.data.model.NovelPreferenceEntity @@ -79,6 +82,13 @@ class OtherUserLibraryFragment : BaseFragment(f ), ) }, + onEmptyClick = { + showWebsosoToast( + requireContext(), + getString(collection_other_user_empty), + ic_novel_rating_alert, + ) + }, ) } } diff --git a/app/src/main/res/layout/fragment_other_user_library.xml b/app/src/main/res/layout/fragment_other_user_library.xml index 6e0136a59..f301bc155 100644 --- a/app/src/main/res/layout/fragment_other_user_library.xml +++ b/app/src/main/res/layout/fragment_other_user_library.xml @@ -19,7 +19,14 @@ android:id="@+id/cv_other_user_collection" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintTop_toBottomOf="@id/barrier_other_user_genre" /> + app:layout_constraintTop_toBottomOf="@id/divider_other_user_library_storage" /> + + + app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toBottomOf="@id/divider_other_user_library_collection"> + app:layout_constraintTop_toBottomOf="@id/divider_other_user_library_collection"> + + + app:layout_constraintTop_toBottomOf="@id/divider_other_user_library_genre"> + app:layout_constraintTop_toBottomOf="@id/divider_other_user_library_genre"> 컬렉션 컬렉션 만들기 등록된 컬렉션이 없어요 + 컬렉션을 등록하지 않은 유저에요 좋아요한 컬렉션이 없어요 서재에 등록한 작품이 없어요 100개까지 선택이 가능해요 diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt index 744aba0a0..fa2f1b976 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionPreview.kt @@ -1,6 +1,4 @@ package com.into.websoso.feature.collection - -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,7 +20,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -46,12 +43,12 @@ fun CollectionPreview( onCollectionClick: (Long) -> Unit, modifier: Modifier = Modifier, userId: Long? = null, + onEmptyClick: () -> Unit = {}, ) { val viewModel: CollectionPreviewViewModel = hiltViewModel(key = "collection-preview-${userId ?: "me"}") val page by viewModel.page.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val isError by viewModel.isError.collectAsStateWithLifecycle() - val context = LocalContext.current LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh(userId) } Column(modifier.fillMaxWidth().background(White)) { val current = page @@ -60,15 +57,19 @@ fun CollectionPreview( collectionCount = current.totalCount, onClick = { if (userId != null && current.totalCount == 0) { - Toast.makeText(context, R.string.collection_empty, Toast.LENGTH_SHORT).show() + onEmptyClick() } else { onListClick() } }, + showCount = userId == null, ) if (current.collections.isNotEmpty()) { Row( - Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 20.dp), + Modifier + .fillMaxWidth() + .padding(horizontal = if (userId == null) 20.dp else 16.dp) + .padding(bottom = if (userId == null) 20.dp else 16.dp), horizontalArrangement = Arrangement.spacedBy(28.dp), ) { current.collections.take(3).forEach { collection -> diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt index 73956bef8..e3f8f9b94 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon @@ -20,6 +21,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.into.websoso.core.common.extensions.debouncedClickable +import com.into.websoso.core.designsystem.theme.Black import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Primary100 import com.into.websoso.core.designsystem.theme.WebsosoTheme @@ -31,32 +33,43 @@ fun CollectionEntry( onClick: () -> Unit, modifier: Modifier = Modifier, collectionCount: Int = 0, + showCount: Boolean = true, ) { Row( modifier = modifier .fillMaxWidth() .background(White) .debouncedClickable(onClick = onClick) - .padding( - start = 20.dp, - top = 20.dp, - end = 20.dp, - bottom = if (collectionCount > 0) 16.dp else 20.dp, + .then( + if (showCount) { + Modifier.padding( + start = 20.dp, + top = 20.dp, + end = 20.dp, + bottom = if (collectionCount > 0) 16.dp else 20.dp, + ) + } else { + Modifier.height(44.dp).padding(horizontal = 16.dp) + }, ), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = buildAnnotatedString { - append(stringResource(R.string.collection_title)) - append(" ") - withStyle(style = SpanStyle(color = Primary100)) { - append(collectionCount.toString()) - if (collectionCount > 0) append(stringResource(R.string.collection_count_suffix)) + text = if (showCount) { + buildAnnotatedString { + append(stringResource(R.string.collection_title)) + append(" ") + withStyle(style = SpanStyle(color = Primary100)) { + append(collectionCount.toString()) + if (collectionCount > 0) append(stringResource(R.string.collection_count_suffix)) + } + if (collectionCount == 0) append(stringResource(R.string.collection_count_suffix)) } - if (collectionCount == 0) append(stringResource(R.string.collection_count_suffix)) + } else { + buildAnnotatedString { append(stringResource(R.string.collection_title)) } }, - color = Gray300, - style = WebsosoTheme.typography.title2, + color = if (showCount) Gray300 else Black, + style = if (showCount) WebsosoTheme.typography.title2 else WebsosoTheme.typography.title1, ) Spacer(modifier = Modifier.weight(1f)) Icon( From d4d2a5f86521e1e9b3f55ee1a4d298dd5f6edc03 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 16:14:46 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=8B=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionNovelSearchScreen.kt`: - `BackHandler`를 사용하여 시스템 뒤로가기 이벤트 제어 로직 추가 - `onDiscard` 콜백 유무에 따라 즉시 뒤로가기 또는 폐기 확인 다이얼로그(`CollectionConfirmDialog`) 표시 분기 처리 - 상단 앱바의 뒤로가기 버튼 클릭 시에도 동일한 폐기 확인 로직이 적용되도록 수정 --- .../collection/CollectionNovelSearchScreen.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt index 371d6f08e..f6cd931a6 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt @@ -1,6 +1,7 @@ package com.into.websoso.feature.collection import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -59,6 +60,7 @@ import com.into.websoso.core.resource.R.string.normal_explore_not_exist_result import com.into.websoso.core.resource.R.string.novel_inquire_link import com.into.websoso.data.novel.model.NovelSearchEntity import com.into.websoso.feature.collection.component.CollectionAppBar +import com.into.websoso.feature.collection.component.CollectionConfirmDialog import com.into.websoso.feature.collection.component.CollectionNetworkError import com.into.websoso.feature.collection.component.CollectionNovelSearchField import com.into.websoso.feature.collection.component.CollectionNovelSearchItem @@ -70,6 +72,7 @@ import kotlinx.coroutines.flow.flowOf @Composable internal fun CollectionNovelSearchRoute( onNavigateBack: () -> Unit, + onDiscard: (() -> Unit)?, onNavigateToLibraryNovelSelection: () -> Unit, viewModel: CollectionNovelSearchViewModel, ) { @@ -98,6 +101,7 @@ internal fun CollectionNovelSearchRoute( }, onDeleteNovel = viewModel::removeNovel, onNavigateBack = onNavigateBack, + onDiscard = onDiscard, onNavigateToLibraryNovelSelection = onNavigateToLibraryNovelSelection, ) } @@ -111,6 +115,7 @@ internal fun CollectionNovelSearchScreen( onAddNovel: (NovelSearchEntity) -> Unit, onDeleteNovel: (Long) -> Unit, onNavigateBack: () -> Unit, + onDiscard: (() -> Unit)?, onNavigateToLibraryNovelSelection: () -> Unit, modifier: Modifier = Modifier, ) { @@ -119,6 +124,10 @@ internal fun CollectionNovelSearchScreen( } val searchFocusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current + var showDiscard by rememberSaveable { mutableStateOf(false) } + val requestBack = { + if (onDiscard == null) onNavigateBack() else showDiscard = true + } val selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId } val isInitialLoading = submittedQuery.isNotBlank() && @@ -129,6 +138,7 @@ internal fun CollectionNovelSearchScreen( searchFocusRequester.requestFocus() keyboardController?.show() } + BackHandler(onBack = requestBack) Column( modifier = modifier @@ -139,7 +149,7 @@ internal fun CollectionNovelSearchScreen( CollectionAppBar( title = stringResource(collection_create_novel_list), actionLabel = stringResource(collection_create_complete), - onNavigateBack = onNavigateBack, + onNavigateBack = requestBack, onActionClick = onNavigateBack, isActionEnabled = selectedNovels.isNotEmpty(), ) @@ -254,6 +264,13 @@ internal fun CollectionNovelSearchScreen( } } } + if (showDiscard) { + CollectionConfirmDialog( + isDelete = false, + onDismiss = { showDiscard = false }, + onConfirm = { onDiscard?.invoke() }, + ) + } } @Composable @@ -307,6 +324,7 @@ private fun CollectionNovelSearchScreenPreview() { onAddNovel = {}, onDeleteNovel = {}, onNavigateBack = {}, + onDiscard = null, onNavigateToLibraryNovelSelection = {}, ) } From 35786943c72d10211f15fa05b7324688bd02c5c5 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 16:19:02 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=94=A5=EB=A7=81=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CollectionDeepLink.kt`: 카카오 링크 URI에서 `collectionId`를 추출하는 파싱 로직 및 Intent 간 ID 전달을 위한 `forward` 함수 구현 - `CollectionDeepLinkTest.kt`: 다양한 URI 형식 및 예외 상황에 대한 딥링크 파싱 단위 테스트 추가 - `AndroidManifest.xml`: `MainActivity`에 카카오 링크 스킴(`kakao${kakaoAppKey}://kakaolink`) 처리를 위한 `intent-filter` 설정 - `SplashActivity.kt`: 앱 진입 시 딥링크를 확인하여 `PENDING_COLLECTION_ID`를 Intent에 저장하고 각 화면 전환 시 전달 로직 구현 - `MainActivity.kt`: 전달받은 `PENDING_COLLECTION_ID`가 있을 경우 `CollectionActivity`로 이동하는 로직 추가 - `SignInScreen.kt` & `LoginActivity.kt`: 네비게이션 로직을 콜백 방식으로 리팩토링하고 로그인 성공 시 딥링크 데이터 전달 지원 - `OnboardingActivity.kt` & `WelcomeActivity.kt`: 회원가입 및 온보딩 과정 중에도 딥링크 데이터가 유지되도록 Intent 전달 로직 수정 --- app/src/main/AndroidManifest.xml | 10 +++++ .../ui/collection/CollectionDeepLink.kt | 40 +++++++++++++++++ .../into/websoso/ui/login/LoginActivity.kt | 8 +++- .../com/into/websoso/ui/main/MainActivity.kt | 11 +++++ .../ui/onboarding/OnboardingActivity.kt | 3 +- .../ui/onboarding/welcome/WelcomeActivity.kt | 3 +- .../into/websoso/ui/splash/SplashActivity.kt | 33 +++++++++++++- .../ui/collection/CollectionDeepLinkTest.kt | 45 +++++++++++++++++++ .../websoso/feature/signin/SignInScreen.kt | 10 ++--- 9 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/collection/CollectionDeepLink.kt create mode 100644 app/src/test/java/com/into/websoso/ui/collection/CollectionDeepLinkTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2446f0f1a..39ab79b20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,16 @@ + + + + + + + + 0L } + }.getOrNull() + + fun forward( + source: Intent, + destination: Intent, + ): Intent = + destination.apply { + source.getLongExtra(PENDING_COLLECTION_ID, 0L).takeIf { it > 0L }?.let { + putExtra(PENDING_COLLECTION_ID, it) + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt b/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt index a6eb0f5ce..0e68add4e 100644 --- a/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt @@ -17,6 +17,7 @@ import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.core.common.util.setupSystemBarIconColor import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.feature.signin.SignInScreen +import com.into.websoso.ui.collection.CollectionDeepLink import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -39,12 +40,17 @@ class LoginActivity : ComponentActivity() { WebsosoTheme { SignInScreen( authClient = { platform -> authClient[platform] }, - websosoNavigator = websosoNavigator, + onNavigateToHome = { websosoNavigator.navigateToMainActivity(::startDestination) }, + onNavigateToOnboarding = { websosoNavigator.navigateToOnboardingActivity(::startDestination) }, ) } } } + private fun startDestination(destination: Intent) { + startActivity(CollectionDeepLink.forward(intent, destination)) + } + companion object { fun getIntent(context: Context): Intent = Intent(context, LoginActivity::class.java).apply { diff --git a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt index c1a4c44f0..ec42a6813 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt @@ -23,6 +23,8 @@ import com.into.websoso.core.common.util.showWebsosoSnackBar import com.into.websoso.core.resource.R.drawable.ic_blocked_user_snack_bar import com.into.websoso.core.resource.R.string.main_back_press import com.into.websoso.databinding.ActivityMainBinding +import com.into.websoso.ui.collection.CollectionActivity +import com.into.websoso.ui.collection.CollectionDeepLink import com.into.websoso.ui.common.dialog.LoginRequestDialogFragment import com.into.websoso.ui.main.MainActivity.FragmentType.FEED import com.into.websoso.ui.main.MainActivity.FragmentType.HOME @@ -62,6 +64,15 @@ class MainActivity : BaseActivity(activity_main) { ) { _, _ -> handleNavigation(LIBRARY) } + if (savedInstanceState == null) openPendingCollection() + } + + private fun openPendingCollection() { + val collectionId = intent.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) + intent.removeExtra(CollectionDeepLink.PENDING_COLLECTION_ID) + if (collectionId > 0L) { + startActivity(CollectionActivity.getIntent(this, collectionId = collectionId)) + } } private fun setupBackButtonListener() { diff --git a/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt index 209f2fb14..9988ede36 100644 --- a/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt @@ -10,6 +10,7 @@ import com.into.websoso.R import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.databinding.ActivityOnboardingBinding +import com.into.websoso.ui.collection.CollectionDeepLink import com.into.websoso.ui.onboarding.model.OnboardingPage import com.into.websoso.ui.onboarding.welcome.WelcomeActivity import com.into.websoso.ui.termsAgreement.TermsAgreementDialogBottomSheet @@ -54,7 +55,7 @@ class OnboardingActivity : BaseActivity(R.layout.acti viewModel.isUserProfileSubmit.observe(this) { isUserProfileSubmit -> if (isUserProfileSubmit) { val nickname = viewModel.currentNicknameInput.value ?: "웹소소" - startActivity(WelcomeActivity.getIntent(this, nickname)) + startActivity(CollectionDeepLink.forward(intent, WelcomeActivity.getIntent(this, nickname))) finish() } } diff --git a/app/src/main/java/com/into/websoso/ui/onboarding/welcome/WelcomeActivity.kt b/app/src/main/java/com/into/websoso/ui/onboarding/welcome/WelcomeActivity.kt index 69db4f2ea..b70b96e11 100644 --- a/app/src/main/java/com/into/websoso/ui/onboarding/welcome/WelcomeActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/onboarding/welcome/WelcomeActivity.kt @@ -7,6 +7,7 @@ import com.into.websoso.R.layout.activity_welcome import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.resource.R.string.welcome_hello_user import com.into.websoso.databinding.ActivityWelcomeBinding +import com.into.websoso.ui.collection.CollectionDeepLink import com.into.websoso.ui.main.MainActivity class WelcomeActivity : BaseActivity(activity_welcome) { @@ -25,7 +26,7 @@ class WelcomeActivity : BaseActivity(activity_welcome) { private fun onCompleteButtonClick() { binding.btnWelcomeStart.setOnClickListener { - startActivity(MainActivity.getIntent(this, true)) + startActivity(CollectionDeepLink.forward(intent, MainActivity.getIntent(this, true))) finish() } } diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt index 148a9270a..d36672109 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt @@ -1,15 +1,18 @@ package com.into.websoso.ui.splash import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle import android.provider.Settings.Secure.ANDROID_ID import android.provider.Settings.Secure.getString import androidx.activity.viewModels +import com.into.websoso.BuildConfig import com.into.websoso.R import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.databinding.ActivitySplashBinding +import com.into.websoso.ui.collection.CollectionDeepLink import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog @@ -27,10 +30,31 @@ class SplashActivity : BaseActivity(R.layout.activity_spl override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + prepareCollectionDeepLink() + updateUserDeviceIdentifier() collectUiEffect() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + prepareCollectionDeepLink() + } + + private fun prepareCollectionDeepLink() { + intent.removeExtra(CollectionDeepLink.PENDING_COLLECTION_ID) + if (intent.action == Intent.ACTION_VIEW) { + val collectionId = CollectionDeepLink.parseCollectionId( + link = intent.dataString, + expectedScheme = "kakao${BuildConfig.KAKAO_APP_KEY}", + ) + if (collectionId != null) { + intent.putExtra(CollectionDeepLink.PENDING_COLLECTION_ID, collectionId) + } + } + } + @SuppressLint("HardwareIds") private fun updateUserDeviceIdentifier() { val deviceId = getString(contentResolver, ANDROID_ID).orEmpty() @@ -40,13 +64,18 @@ class SplashActivity : BaseActivity(R.layout.activity_spl private fun collectUiEffect() { splashViewModel.uiEffect.collectWithLifecycle(this) { uiEffect -> when (uiEffect) { - NavigateToLogin -> websosoNavigator.navigateToLoginActivity(::startActivity) - NavigateToMain -> websosoNavigator.navigateToMainActivity(::startActivity) + NavigateToLogin -> websosoNavigator.navigateToLoginActivity(::startDestination) + NavigateToMain -> websosoNavigator.navigateToMainActivity(::startDestination) ShowDialog -> showMinimumVersionDialog() } } } + private fun startDestination(destination: Intent) { + startActivity(CollectionDeepLink.forward(intent, destination)) + finish() + } + private fun showMinimumVersionDialog() { val dialog = MinimumVersionDialogFragment.newInstance() dialog.isCancelable = false diff --git a/app/src/test/java/com/into/websoso/ui/collection/CollectionDeepLinkTest.kt b/app/src/test/java/com/into/websoso/ui/collection/CollectionDeepLinkTest.kt new file mode 100644 index 000000000..85a2e0017 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/collection/CollectionDeepLinkTest.kt @@ -0,0 +1,45 @@ +package com.into.websoso.ui.collection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CollectionDeepLinkTest { + @Test + fun `read a positive collection ID from the expected Kakao link`() { + assertEquals(123L, parse("kakaotest://kakaolink?collectionId=123")) + assertEquals(123L, parse("kakaotest://kakaolink/?collectionId=123&source=share")) + assertEquals(123L, parse("kakaotest://kakaolink?collectionId=%31%32%33")) + assertEquals(Long.MAX_VALUE, parse("kakaotest://kakaolink?collectionId=${Long.MAX_VALUE}")) + } + + @Test + fun `reject malformed IDs and ambiguous duplicate parameters`() { + listOf("", "0", "-1", "+1", "1.5", "abc", "9223372036854775808", "%20", "%ZZ").forEach { + assertNull(parse("kakaotest://kakaolink?collectionId=$it")) + } + assertNull(parse("kakaotest://kakaolink?collectionId=1&collectionId=2")) + assertNull(parse("kakaotest://kakaolink?collectionId=1&collection%49d=2")) + assertNull(parse("kakaotest://kakaolink?collectionId")) + assertNull(parse("kakaotest://kakaolink?otherId=1")) + } + + @Test + fun `reject unrelated routes and invalid URLs`() { + listOf( + null, + "", + "not a URL", + "https://kakaolink?collectionId=1", + "kakaoother://kakaolink?collectionId=1", + "kakaotest://oauth?collectionId=1", + "kakaotest://kakaolink.evil?collectionId=1", + "kakaotest://user@kakaolink?collectionId=1", + "kakaotest://kakaolink:80?collectionId=1", + "kakaotest://kakaolink/other?collectionId=1", + "kakaotest://kakaolink?collectionId=1#fragment", + ).forEach { assertNull(parse(it)) } + } + + private fun parse(link: String?): Long? = CollectionDeepLink.parseCollectionId(link, "kakaotest") +} diff --git a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt index 8365c8729..c9a48132a 100644 --- a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt +++ b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt @@ -15,14 +15,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.into.websoso.core.auth.AuthClient import com.into.websoso.core.auth.AuthPlatform import com.into.websoso.core.common.extensions.collectAsEventWithLifecycle -import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.core.designsystem.theme.Gray50 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.feature.signin.UiEffect.NavigateToHome @@ -37,10 +35,10 @@ import com.into.websoso.feature.signin.component.SignInButtons @Composable fun SignInScreen( authClient: (platform: AuthPlatform) -> AuthClient?, - websosoNavigator: NavigatorProvider, + onNavigateToHome: () -> Unit, + onNavigateToOnboarding: () -> Unit, signInViewModel: SignInViewModel = hiltViewModel(), ) { - val context = LocalContext.current val latestEffect by rememberUpdatedState(signInViewModel.uiEffect) val pagerState = rememberPagerState { Onboarding_Images.size } @@ -54,9 +52,9 @@ fun SignInScreen( ShowToast -> {} - NavigateToHome -> websosoNavigator.navigateToMainActivity(context::startActivity) + NavigateToHome -> onNavigateToHome() - NavigateToOnboarding -> websosoNavigator.navigateToOnboardingActivity(context::startActivity) + NavigateToOnboarding -> onNavigateToOnboarding() } } From a479b07a22c92f55d7c3cb956e66a41eb9a9a8aa Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 16:21:21 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **공유 로직 및 UI 구현**: - `CollectionActivity`: 카카오톡 공유 실행 및 에러 처리 로직 추가. 공유 중 상태(`isSharing`) 관리 및 성공 시/재진입 시 초기화. - `CollectionDetailScreen`: 공유 버튼에 카카오톡 공유 이벤트 연결. 비공개 컬렉션인 경우 잠금 아이콘 표시 및 텍스트 변경. 공유 준비 중 로딩 UI 대응. - `CollectionKakaoShare`: 작품 수(1~3개)에 따른 카카오톡 메시지 템플릿 ID 매핑 로직 구현. - **데이터 모델링**: - `CollectionShareContent`: 공유에 필요한 데이터(ID, 제목, 닉네임, 이미지 URL 리스트)를 추출하는 모델 추가. - 대표 작품을 리스트의 가장 앞에 배치하고 최대 3개의 표지를 추출하는 도메인 로직 구현. - **기타 수정**: - `CollectionNovelSearchRoute`: 편집 모드에서 `onDiscard` 핸들러 연결. - `CollectionDetailScreen`: 정렬 및 리스트 렌더링 시 사용되는 UI 컴포저블의 코드 스타일(들여쓰기 및 정렬) 최적화. - `Firebase Analytics`: 공유 실패 또는 비정상적인 상태(이미지 부족 등)에서 공유가 차단될 경우를 위한 로깅(`collection_share_blocked`) 추가. - `strings.xml`: 공유하기 및 공유 준비 중 상태 메시지 리소스 추가. - `build.gradle.kts`: 카카오 공유 SDK(`v2-share`) 의존성 추가. --- app/build.gradle.kts | 1 + .../ui/collection/CollectionActivity.kt | 65 ++++++ .../ui/collection/CollectionKakaoShare.kt | 27 +++ .../ui/collection/CollectionKakaoShareTest.kt | 21 ++ core/resource/src/main/res/values/strings.xml | 4 + .../collection/CollectionDetailScreen.kt | 185 +++++++++++++++--- .../feature/collection/CollectionNavHost.kt | 8 + .../model/CollectionShareContent.kt | 23 +++ .../collection/CollectionShareContentTest.kt | 52 +++++ gradle/libs.versions.toml | 1 + 10 files changed, 361 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/collection/CollectionKakaoShare.kt create mode 100644 app/src/test/java/com/into/websoso/ui/collection/CollectionKakaoShareTest.kt create mode 100644 feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionShareContent.kt create mode 100644 feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionShareContentTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19e017c57..9d020a9b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,6 +134,7 @@ dependencies { // Third-party SDK implementation(libs.kakao) // 카카오 로그인 API + implementation(libs.kakao.share) // 카카오톡 공유 API implementation(libs.amplitude) // Amplitude // Firebase diff --git a/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt b/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt index 0ee410a2d..6cc7e4bf3 100644 --- a/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/collection/CollectionActivity.kt @@ -3,15 +3,27 @@ package com.into.websoso.ui.collection import android.content.Context import android.content.Intent import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.google.firebase.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.analytics.logEvent import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R import com.into.websoso.feature.collection.CollectionNavHost +import com.into.websoso.feature.collection.model.CollectionShareContent import com.into.websoso.ui.novelDetail.NovelDetailActivity +import com.kakao.sdk.share.ShareClient import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class CollectionActivity : ComponentActivity() { + private var isSharing by mutableStateOf(false) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -20,6 +32,9 @@ class CollectionActivity : ComponentActivity() { CollectionNavHost( onNavigateBack = ::finish, onNovelClick = { startActivity(NovelDetailActivity.getIntent(this, it)) }, + onShare = ::shareCollection, + onShareBlocked = ::logShareBlocked, + isSharing = isSharing, initialCollectionId = intent.getLongExtra(COLLECTION_ID, 0L).takeIf { it > 0L }, userId = intent.getLongExtra(USER_ID, 0L).takeIf { it > 0L }, ) @@ -27,6 +42,56 @@ class CollectionActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + isSharing = false + } + + private fun shareCollection(content: CollectionShareContent) { + if (isSharing) return + if (!ShareClient.instance.isKakaoTalkSharingAvailable(this)) { + Toast.makeText(this, R.string.collection_share_kakao_required, Toast.LENGTH_SHORT).show() + return + } + isSharing = true + runCatching { + ShareClient.instance.shareCustom( + context = this, + templateId = CollectionKakaoShare.templateId(content), + templateArgs = CollectionKakaoShare.templateArgs(content), + ) { result, error -> + isSharing = false + if (isFinishing || isDestroyed) return@shareCustom + if (error != null || result == null) { + showShareError() + } else { + runCatching { startActivity(result.intent) }.onFailure { showShareError() } + } + } + }.onFailure { + isSharing = false + showShareError() + } + } + + private fun showShareError() { + Toast.makeText(this, R.string.collection_share_failed, Toast.LENGTH_SHORT).show() + } + + private fun logShareBlocked( + collectionId: Long, + novelCount: Int, + novelsSize: Int, + isPublic: Boolean, + ) { + Firebase.analytics.logEvent("collection_share_blocked") { + param("collection_id", collectionId) + param("novel_count", novelCount.toLong()) + param("novels_size", novelsSize.toLong()) + param("is_public", if (isPublic) 1L else 0L) + } + } + companion object { private const val COLLECTION_ID = "COLLECTION_ID" private const val USER_ID = "USER_ID" diff --git a/app/src/main/java/com/into/websoso/ui/collection/CollectionKakaoShare.kt b/app/src/main/java/com/into/websoso/ui/collection/CollectionKakaoShare.kt new file mode 100644 index 000000000..eb69da650 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/collection/CollectionKakaoShare.kt @@ -0,0 +1,27 @@ +package com.into.websoso.ui.collection + +import com.into.websoso.feature.collection.model.CollectionShareContent + +internal object CollectionKakaoShare { + // 카카오 디벨로퍼스 콘솔의 메시지 템플릿(카카오링크)과 1:1로 연결된 ID. + // 콘솔에서 템플릿을 재생성하면 값이 바뀌므로 함께 갱신해야 한다. + private const val TEMPLATE_ID_ONE_COVER = 136782L + private const val TEMPLATE_ID_TWO_COVERS = 136783L + private const val TEMPLATE_ID_THREE_COVERS = 136784L + + fun templateId(content: CollectionShareContent): Long = + when (content.imageUrls.size) { + 1 -> TEMPLATE_ID_ONE_COVER + 2 -> TEMPLATE_ID_TWO_COVERS + 3 -> TEMPLATE_ID_THREE_COVERS + else -> error("Collection sharing requires 1 to 3 covers") + } + + fun templateArgs(content: CollectionShareContent): Map = + buildMap { + put("collectionId", content.collectionId.toString()) + put("TITLE", content.title) + put("NICKNAME", content.nickname) + content.imageUrls.forEachIndexed { index, url -> put("IMAGE${index + 1}", url) } + } +} diff --git a/app/src/test/java/com/into/websoso/ui/collection/CollectionKakaoShareTest.kt b/app/src/test/java/com/into/websoso/ui/collection/CollectionKakaoShareTest.kt new file mode 100644 index 000000000..9d5a2b04d --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/collection/CollectionKakaoShareTest.kt @@ -0,0 +1,21 @@ +package com.into.websoso.ui.collection + +import com.into.websoso.feature.collection.model.CollectionShareContent +import org.junit.Assert.assertEquals +import org.junit.Test + +class CollectionKakaoShareTest { + @Test + fun `표지 수에 맞는 템플릿과 사용자 인자를 전달한다`() { + listOf(136782L, 136783L, 136784L).forEachIndexed { index, templateId -> + val images = (1..index + 1).map { "https://example.com/$it.jpg" } + val content = CollectionShareContent(123L, "추천 컬렉션", "작성자", images) + assertEquals(templateId, CollectionKakaoShare.templateId(content)) + assertEquals( + mapOf("collectionId" to "123", "TITLE" to "추천 컬렉션", "NICKNAME" to "작성자") + + images.mapIndexed { imageIndex, url -> "IMAGE${imageIndex + 1}" to url }, + CollectionKakaoShare.templateArgs(content), + ) + } + } +} diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index ee4f006b2..dd97bcee1 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -468,6 +468,10 @@ 등록 오래된순 좋아요 (%1$d) 공유하기 + 공유 준비 중 + 컬렉션 공유에 실패했어요. 다시 시도해주세요. + 컬렉션 정보가 오래됐어요. 새로고침 후 다시 시도해주세요. + 카카오톡을 설치한 후 공유해주세요. 컬렉션 수정을 그만할까요? 그만하기 계속 작성 diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt index d15481967..e0fb4071a 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt @@ -1,5 +1,6 @@ package com.into.websoso.feature.collection +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -86,6 +87,7 @@ import com.into.websoso.domain.collection.model.CollectionSortCriteria import com.into.websoso.feature.collection.component.CollectionConfirmDialog import com.into.websoso.feature.collection.component.CollectionNetworkError import com.into.websoso.feature.collection.component.ellipsize +import com.into.websoso.feature.collection.model.CollectionShareContent import kotlinx.coroutines.launch @Composable @@ -94,6 +96,9 @@ internal fun CollectionDetailScreen( onEdit: (Long) -> Unit, onDeleted: () -> Unit, onNovelClick: (Long) -> Unit, + onShare: (CollectionShareContent) -> Unit, + onShareBlocked: (collectionId: Long, novelCount: Int, novelsSize: Int, isPublic: Boolean) -> Unit, + isSharing: Boolean, viewModel: CollectionDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -127,7 +132,8 @@ internal fun CollectionDetailScreen( } } } else { - val novels = if (state.sort == CollectionSortCriteria.RECENT) collection.novels else collection.novels.asReversed() + val novels = + if (state.sort == CollectionSortCriteria.RECENT) collection.novels else collection.novels.asReversed() PullToRefreshBox( isRefreshing = state.isLoading, onRefresh = viewModel::refresh, @@ -141,7 +147,36 @@ internal fun CollectionDetailScreen( contentPadding = PaddingValues(bottom = 20.dp), ) { item(span = { GridItemSpan(maxLineSpan) }) { - CollectionDetailHeader(collection, state.isBusy || state.isLoading, viewModel::toggleLike) + CollectionDetailHeader( + collection = collection, + isBusy = state.isBusy || state.isLoading, + isSharing = isSharing, + onLike = viewModel::toggleLike, + onShare = { + val content = CollectionShareContent.from(collection) + if (content != null) { + onShare(content) + } else { + Log.w( + "CollectionDetailScreen", + "collection ${collection.id} failed to build share content: " + + "novelCount=${collection.novelCount}, novels=${collection.novels.size}, " + + "representativeNovelId=${collection.representativeNovelId}", + ) + onShareBlocked( + collection.id, + collection.novelCount, + collection.novels.size, + collection.isPublic, + ) + snackbarScope.launch { + snackbar.showSnackbar( + context.getString(R.string.collection_share_data_outdated), + ) + } + } + }, + ) } item(span = { GridItemSpan(maxLineSpan) }) { Row( @@ -150,7 +185,10 @@ internal fun CollectionDetailScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - stringResource(R.string.collection_create_added_novel_count, collection.novelCount), + stringResource( + R.string.collection_create_added_novel_count, + collection.novelCount, + ), color = Gray200, style = WebsosoTheme.typography.body3, ) @@ -159,19 +197,34 @@ internal fun CollectionDetailScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon(painterResource(R.drawable.ic_library_sort), null, Modifier.size(16.dp), tint = Gray200) - Text(state.sort.label, color = Gray300, style = WebsosoTheme.typography.body3) + Icon( + painterResource(R.drawable.ic_library_sort), + null, + Modifier.size(16.dp), + tint = Gray200, + ) + Text( + state.sort.label, + color = Gray300, + style = WebsosoTheme.typography.body3, + ) } } } - items(novels.chunked(3), key = { it.first().id }, span = { GridItemSpan(maxLineSpan) }) { row -> + items( + novels.chunked(3), + key = { it.first().id }, + span = { GridItemSpan(maxLineSpan) }, + ) { row -> Row( Modifier.fillMaxWidth().padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { row.forEach { novel -> Column( - Modifier.weight(1f).clickable(role = Role.Button) { onNovelClick(novel.id) }, + Modifier + .weight(1f) + .clickable(role = Role.Button) { onNovelClick(novel.id) }, verticalArrangement = Arrangement.spacedBy(6.dp), ) { NetworkImage( @@ -180,7 +233,10 @@ internal fun CollectionDetailScreen( contentScale = ContentScale.Crop, alignment = Alignment.BottomCenter, placeholder = painterResource(R.drawable.img_collection_empty_cover), - modifier = Modifier.fillMaxWidth().height(160.dp).clip(RoundedCornerShape(8.dp)), + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(8.dp)), ) Text( novel.title, @@ -258,8 +314,14 @@ private fun CollectionDetailMenu( Column( Modifier .width(122.dp) - .dropShadow(shape, Shadow(radius = 7.5.dp, color = Black60.copy(alpha = 0.11f), offset = DpOffset(0.dp, 2.dp))) - .clip(shape) + .dropShadow( + shape, + Shadow( + radius = 7.5.dp, + color = Black60.copy(alpha = 0.11f), + offset = DpOffset(0.dp, 2.dp), + ), + ).clip(shape) .background(White), ) { Text( @@ -267,7 +329,10 @@ private fun CollectionDetailMenu( color = Black, style = WebsosoTheme.typography.body2, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, role = Role.Button, onClick = onEdit).padding(15.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Button, onClick = onEdit) + .padding(15.dp), ) HorizontalDivider(thickness = 0.7.dp, color = Gray50) Text( @@ -341,7 +406,9 @@ private fun DetailAppBar( private fun CollectionDetailHeader( collection: CollectionDetail, isBusy: Boolean, + isSharing: Boolean, onLike: () -> Unit, + onShare: () -> Unit, ) { Box(Modifier.fillMaxWidth().heightIn(min = 328.dp)) { NetworkImage( @@ -361,18 +428,34 @@ private fun CollectionDetailHeader( ), ) Column( - Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = 156.dp, bottom = 24.dp), + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 156.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - S3Image(imageUrl = collection.owner.avatarImageUrl, modifier = Modifier.size(32.dp).clip(RoundedCornerShape(10.dp))) - Text(collection.owner.nickname, color = White, style = WebsosoTheme.typography.body4) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + S3Image( + imageUrl = collection.owner.avatarImageUrl, + modifier = Modifier.size(32.dp).clip(RoundedCornerShape(10.dp)), + ) + Text( + collection.owner.nickname, + color = White, + style = WebsosoTheme.typography.body4, + ) } Text(collection.name, color = White, style = WebsosoTheme.typography.headline1) collection.description?.takeIf(String::isNotBlank)?.let { Text(it, color = White, style = WebsosoTheme.typography.body3) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 6.dp), + ) { Row( Modifier .weight(1f) @@ -380,8 +463,15 @@ private fun CollectionDetailHeader( .clip(RoundedCornerShape(15.dp)) .background(if (collection.isLiked) Primary30 else White) .border(1.dp, Primary100, RoundedCornerShape(15.dp)) - .toggleable(value = collection.isLiked, enabled = !isBusy, role = Role.Checkbox) { onLike() }, - horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + .toggleable( + value = collection.isLiked, + enabled = !isBusy, + role = Role.Checkbox, + ) { onLike() }, + horizontalArrangement = Arrangement.spacedBy( + 9.dp, + Alignment.CenterHorizontally, + ), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -402,13 +492,40 @@ private fun CollectionDetailHeader( .height(40.dp) .clip(RoundedCornerShape(15.dp)) .background(if (collection.isPublic) Primary100 else Gray80) - .clickable(enabled = false, role = Role.Button) {}, - horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + .clickable( + enabled = collection.isPublic && !isBusy && !isSharing, + role = Role.Button, + onClick = onShare, + ), + horizontalArrangement = Arrangement.spacedBy( + 9.dp, + Alignment.CenterHorizontally, + ), verticalAlignment = Alignment.CenterVertically, ) { - if (!collection.isPublic) Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = Gray200) + if (isSharing) { + CircularProgressIndicator( + Modifier.size(16.dp), + color = White, + strokeWidth = 2.dp, + ) + } + if (!collection.isPublic) { + Icon( + painterResource(R.drawable.ic_lock), + null, + Modifier.size(20.dp), + tint = Gray200, + ) + } Text( - stringResource(if (collection.isPublic) R.string.collection_share else R.string.collection_create_private), + stringResource( + when { + !collection.isPublic -> R.string.collection_create_private + isSharing -> R.string.collection_share_preparing + else -> R.string.collection_share + }, + ), color = if (collection.isPublic) White else Gray200, style = WebsosoTheme.typography.body4, ) @@ -442,7 +559,10 @@ private fun CollectionSortSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), ) { - Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { CollectionSortCriteria.entries.forEach { criteria -> Row( Modifier @@ -453,13 +573,26 @@ private fun CollectionSortSheet( onSelected(criteria) onDismiss() }.padding(vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy( + 12.dp, + Alignment.CenterHorizontally, + ), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(20.dp)) { - if (criteria == selected) Icon(painterResource(R.drawable.ic_library_sort_check), null, tint = Primary100) + if (criteria == selected) { + Icon( + painterResource(R.drawable.ic_library_sort_check), + null, + tint = Primary100, + ) + } } - Text(criteria.label, color = if (criteria == selected) Black else Gray200, style = WebsosoTheme.typography.body2) + Text( + criteria.label, + color = if (criteria == selected) Black else Gray200, + style = WebsosoTheme.typography.body2, + ) Spacer(Modifier.size(20.dp)) } } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt index 51a7d934c..954b25ef6 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt @@ -19,6 +19,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.into.websoso.feature.collection.component.CollectionAppBar import com.into.websoso.feature.collection.component.CollectionNetworkError +import com.into.websoso.feature.collection.model.CollectionShareContent private const val COLLECTION_ROUTE = "collection" private const val COLLECTION_DETAIL_ROUTE = "collection/detail/{collectionId}" @@ -30,7 +31,10 @@ private const val COLLECTION_LIBRARY_ROUTE = "collection/editor/{collectionId}/l fun CollectionNavHost( onNavigateBack: () -> Unit, onNovelClick: (Long) -> Unit, + onShare: (CollectionShareContent) -> Unit, + onShareBlocked: (collectionId: Long, novelCount: Int, novelsSize: Int, isPublic: Boolean) -> Unit, modifier: Modifier = Modifier, + isSharing: Boolean = false, initialCollectionId: Long? = null, userId: Long? = null, ) { @@ -62,6 +66,9 @@ fun CollectionNavHost( onEdit = { navController.navigate("collection/editor/$it") }, onDeleted = returnToMyList, onNovelClick = onNovelClick, + onShare = onShare, + onShareBlocked = onShareBlocked, + isSharing = isSharing, ) } composable(COLLECTION_EDITOR_ROUTE, arguments) { entry -> @@ -101,6 +108,7 @@ fun CollectionNavHost( CollectionNovelSearchRoute( viewModel = viewModel, onNavigateBack = { navController.popBackStack() }, + onDiscard = if (id != 0L) returnToMyList else null, onNavigateToLibraryNovelSelection = { navController.navigate("collection/editor/$id/library") }, ) } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionShareContent.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionShareContent.kt new file mode 100644 index 000000000..1a07ca8c0 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionShareContent.kt @@ -0,0 +1,23 @@ +package com.into.websoso.feature.collection.model + +import com.into.websoso.domain.collection.model.CollectionDetail + +data class CollectionShareContent( + val collectionId: Long, + val title: String, + val nickname: String, + val imageUrls: List, +) { + companion object { + internal fun from(collection: CollectionDetail): CollectionShareContent? { + if (!collection.isPublic || collection.id <= 0L || collection.novelCount <= 0) return null + val representative = collection.novels.firstOrNull { it.id == collection.representativeNovelId } ?: return null + val images = (listOf(representative) + collection.novels) + .distinctBy { it.id } + .take(collection.novelCount.coerceAtMost(3)) + .map { it.imageUrl } + if (images.size != collection.novelCount.coerceAtMost(3) || images.any(String::isBlank)) return null + return CollectionShareContent(collection.id, collection.name, collection.owner.nickname, images) + } + } +} diff --git a/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionShareContentTest.kt b/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionShareContentTest.kt new file mode 100644 index 000000000..778ff385a --- /dev/null +++ b/feature/collection/src/test/java/com/into/websoso/feature/collection/CollectionShareContentTest.kt @@ -0,0 +1,52 @@ +package com.into.websoso.feature.collection + +import com.into.websoso.domain.collection.model.CollectionDetail +import com.into.websoso.domain.collection.model.CollectionNovel +import com.into.websoso.domain.collection.model.CollectionOwner +import com.into.websoso.feature.collection.model.CollectionShareContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CollectionShareContentTest { + @Test + fun `대표 표지를 먼저 배치하고 최대 세 작품까지 공유하되 원본 순서는 유지한다`() { + (1..4).forEach { count -> + val collection = collection(count) + val content = requireNotNull(CollectionShareContent.from(collection)) + val expectedIds = (listOf(count) + (1 until count)).take(3) + assertEquals(expectedIds.map { "https://example.com/$it.jpg" }, content.imageUrls) + assertEquals((1..count).map(Int::toLong), collection.novels.map { it.id }) + assertEquals(collection.id, content.collectionId) + assertEquals(collection.name, content.title) + assertEquals(collection.owner.nickname, content.nickname) + } + } + + @Test + fun `비공개 컬렉션과 불완전한 공유 정보는 공유하지 않는다`() { + val collection = collection(2) + assertNull(CollectionShareContent.from(collection.copy(isPublic = false))) + assertNull(CollectionShareContent.from(collection.copy(id = 0))) + assertNull(CollectionShareContent.from(collection.copy(novelCount = 0))) + assertNull(CollectionShareContent.from(collection.copy(novels = emptyList()))) + assertNull(CollectionShareContent.from(collection.copy(representativeNovelId = 99))) + assertNull(CollectionShareContent.from(collection.copy(novelCount = 3))) + assertNull(CollectionShareContent.from(collection.copy(novels = collection.novels.map { it.copy(imageUrl = " ") }))) + } + + private fun collection(count: Int) = + CollectionDetail( + id = 123L, + name = "추천 컬렉션", + description = null, + isPublic = true, + isMine = false, + owner = CollectionOwner(10L, "컬렉션 작성자", ""), + representativeNovelId = count.toLong(), + novelCount = count, + likeCount = 0, + isLiked = false, + novels = (1..count).map { CollectionNovel(it.toLong(), "작품 $it", "https://example.com/$it.jpg", "작가") }, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62d014da9..06411f0f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -135,6 +135,7 @@ swipe-refresh-layout = { module = "androidx.swiperefreshlayout:swiperefreshlayou # Social Login Libraries kakao = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" } +kakao-share = { module = "com.kakao.sdk:v2-share", version.ref = "kakao" } # Dependency Injection Libraries hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } From af9fffb26f20956380501bde662793c4b4455fb9 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 8 Sep 2026 16:27:39 +0900 Subject: [PATCH 17/19] =?UTF-8?q?refactor:=20ktlint=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/into/websoso/feature/signin/SignInScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt index c9a48132a..7d285ee30 100644 --- a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt +++ b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt @@ -52,9 +52,13 @@ fun SignInScreen( ShowToast -> {} - NavigateToHome -> onNavigateToHome() + NavigateToHome -> { + onNavigateToHome() + } - NavigateToOnboarding -> onNavigateToOnboarding() + NavigateToOnboarding -> { + onNavigateToOnboarding() + } } } From aa41e82e4c3bc91456f27f0dbefabb9a8c4c0d32 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 9 Sep 2026 13:30:05 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultCollectionRepository.kt`: - `accountRepository.userIdFlow`에서 ID를 가져올 때 무한 대기를 방지하기 위해 `withTimeoutOrNull` 기반의 `requireUserId` 함수 도입 - 유저 정보 동기화 지연으로 인한 화면 멈춤 현상 방지를 위해 5초의 타임아웃(`USER_ID_AWAIT_TIMEOUT_MS`) 설정 - `getMyCollections` 및 `getMyCollectionPreview`에서 기존 ID 조회 로직을 `requireUserId`로 대체 --- .../collection/DefaultCollectionRepository.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt b/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt index 34a06333c..5c815dad0 100644 --- a/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt +++ b/data/collection/src/main/java/com/into/websoso/data/collection/DefaultCollectionRepository.kt @@ -12,6 +12,7 @@ import com.into.websoso.domain.collection.model.CollectionSortCriteria import com.into.websoso.domain.collection.model.SaveCollection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject internal class DefaultCollectionRepository @@ -23,7 +24,7 @@ internal class DefaultCollectionRepository override fun getMyCollections(): Flow> = createPager { cursor, size -> getUserCollectionPage( - userId = accountRepository.userIdFlow.first { it != 0L }, + userId = requireUserId(), cursor = cursor, size = size, ) @@ -39,7 +40,7 @@ internal class DefaultCollectionRepository override suspend fun getMyCollectionPreview(): CollectionPage = getUserCollectionPage( - userId = accountRepository.userIdFlow.first { it != 0L }, + userId = requireUserId(), cursor = null, size = PREVIEW_SIZE, ) @@ -87,8 +88,21 @@ internal class DefaultCollectionRepository size: Int, ): CollectionPage = api.getUserCollections(userId, cursor, size).toDomain() + // accountRepository.userIdFlow는 로그인 직후 별도 화면(MainViewModel.updateUserInfo 등)이 + // 유저 정보를 성공적으로 받아와야만 채워진다. 그 전에 여기서 무기한 대기하면 + // 콜드 스타트 직후 진입 시 화면이 영원히 멈출 수 있어 타임아웃으로 상한을 둔다. + private suspend fun requireUserId(): Long = + withTimeoutOrNull(USER_ID_AWAIT_TIMEOUT_MS) { + accountRepository.userIdFlow.first { it != 0L } + } ?: error("userId was not resolved within ${USER_ID_AWAIT_TIMEOUT_MS}ms") + private companion object { const val PAGE_SIZE = 10 const val PREVIEW_SIZE = 3 + + // fetchUserInfo()는 MainActivity.onCreate() 시점에 이미 시작되어 있고 + // 정상 응답은 수백 ms 수준이다. 실패하더라도 CollectionPreview에 재시도 버튼이 있어 + // 회복 가능하므로, 네트워크 타임아웃이 아니라 UI 대기 상한으로 5초를 둔다. + const val USER_ID_AWAIT_TIMEOUT_MS = 5_000L } } From 3046e20bbc96dc28c654a87fa2e956951a61891c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 9 Sep 2026 15:41:12 +0900 Subject: [PATCH 19/19] =?UTF-8?q?refactor:=20=EC=BB=AC=EB=A0=89=EC=85=98?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EB=82=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `strings.xml`: 정렬 변경 실패 시 표시할 안내 문구(`collection_sort_failed`) 추가 - `CollectionDetailViewModel.kt`: - `getCollection` 호출 시 현재 UI 상태의 정렬 기준(`sortCriteria`)을 전달하도록 수정 - `sort` 함수 내에 서버 통신 로직을 추가하여 정렬 기준 변경 시 실제 데이터를 다시 불러오도록 개선 - 데이터 로딩 성공 시에만 정렬 상태와 컬렉션 데이터를 동시에 업데이트하도록 처리 - `CollectionDetailScreen.kt`: 뷰모델에서 정렬된 데이터를 직접 가져옴에 따라, UI 단에 존재하던 수동 정렬 로직(`asReversed`) 제거 및 코드 단순화 --- core/resource/src/main/res/values/strings.xml | 1 + .../collection/CollectionDetailScreen.kt | 4 +--- .../collection/CollectionDetailViewModel.kt | 20 ++++++++++++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index dd97bcee1..2f193edb8 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ 컬렉션 수정에 실패했어요 컬렉션 삭제에 실패했어요 좋아요 변경에 실패했어요 + 정렬 변경에 실패했어요 컬렉션 수정 컬렉션 삭제 컬렉션 메뉴 diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt index e0fb4071a..d085d3831 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailScreen.kt @@ -132,8 +132,6 @@ internal fun CollectionDetailScreen( } } } else { - val novels = - if (state.sort == CollectionSortCriteria.RECENT) collection.novels else collection.novels.asReversed() PullToRefreshBox( isRefreshing = state.isLoading, onRefresh = viewModel::refresh, @@ -212,7 +210,7 @@ internal fun CollectionDetailScreen( } } items( - novels.chunked(3), + collection.novels.chunked(3), key = { it.first().id }, span = { GridItemSpan(maxLineSpan) }, ) { row -> diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt index c727e1a9a..919e01cc5 100644 --- a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionDetailViewModel.kt @@ -47,7 +47,7 @@ internal class CollectionDetailViewModel _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { try { - val collection = repository.getCollection(collectionId) + val collection = repository.getCollection(collectionId, sortCriteria = _uiState.value.sort) _uiState.update { it.copy(collection = collection, isLoading = false) } } catch (cancelled: CancellationException) { throw cancelled @@ -58,8 +58,22 @@ internal class CollectionDetailViewModel } fun sort(criteria: CollectionSortCriteria) { - savedStateHandle["sort"] = criteria.name - _uiState.update { it.copy(sort = criteria) } + val current = _uiState.value + if (current.isLoading || current.isBusy || current.isDeleted || current.sort == criteria) return + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + try { + val collection = repository.getCollection(collectionId, sortCriteria = criteria) + // sort와 collection을 한 번에 갱신해 "정렬 표시는 바뀌었는데 목록은 그대로"인 + // 상태가 보이지 않게 한다. 실패하면 아래 catch에서 아무 것도 안 바꾸고 에러만 띄운다. + savedStateHandle["sort"] = criteria.name + _uiState.update { it.copy(collection = collection, sort = criteria, isLoading = false) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _uiState.update { it.copy(isLoading = false, error = R.string.collection_sort_failed) } + } + } } fun toggleLike() {