diff --git a/app/src/main/java/com/into/websoso/data/repository/UserRepository.kt b/app/src/main/java/com/into/websoso/data/repository/UserRepository.kt index 56b70656b..a95213e44 100644 --- a/app/src/main/java/com/into/websoso/data/repository/UserRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/UserRepository.kt @@ -126,6 +126,14 @@ class UserRepository suspend fun fetchNovelDetailFirstLaunched() = userStorage.data.first()[NOVEL_DETAIL_FIRST_LAUNCHED_KEY] ?: true + suspend fun fetchMyPageTooltipConfirmed() = userStorage.data.first()[MY_PAGE_TOOLTIP_CONFIRMED_KEY] ?: false + + suspend fun saveMyPageTooltipConfirmed() { + userStorage.edit { preferences -> + preferences[MY_PAGE_TOOLTIP_CONFIRMED_KEY] = true + } + } + suspend fun fetchNicknameValidity(nickname: String): Boolean = userApi.getNicknameValidity(nickname).isValid suspend fun fetchUserFeeds( @@ -187,6 +195,7 @@ class UserRepository companion object { val NOVEL_DETAIL_FIRST_LAUNCHED_KEY = booleanPreferencesKey("NOVEL_DETAIL_FIRST_LAUNCHED") + val MY_PAGE_TOOLTIP_CONFIRMED_KEY = booleanPreferencesKey("MY_PAGE_TOOLTIP_CONFIRMED") val TERMS_AGREEMENT_CHECKED_KEY = booleanPreferencesKey("terms_agreement_checked") val USER_ID_KEY = stringPreferencesKey("USER_ID") val USER_NICKNAME_KEY = stringPreferencesKey("USER_NICKNAME") 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 6cc7e4bf3..81e35a458 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 @@ -37,6 +37,7 @@ class CollectionActivity : ComponentActivity() { isSharing = isSharing, initialCollectionId = intent.getLongExtra(COLLECTION_ID, 0L).takeIf { it > 0L }, userId = intent.getLongExtra(USER_ID, 0L).takeIf { it > 0L }, + startWithPublicList = intent.getBooleanExtra(START_WITH_PUBLIC_LIST, false), ) } } @@ -95,15 +96,18 @@ class CollectionActivity : ComponentActivity() { companion object { private const val COLLECTION_ID = "COLLECTION_ID" private const val USER_ID = "USER_ID" + private const val START_WITH_PUBLIC_LIST = "START_WITH_PUBLIC_LIST" fun getIntent( context: Context, collectionId: Long? = null, userId: Long? = null, + startWithPublicList: Boolean = false, ): Intent = Intent(context, CollectionActivity::class.java).apply { collectionId?.let { putExtra(COLLECTION_ID, it) } userId?.let { putExtra(USER_ID, it) } + putExtra(START_WITH_PUBLIC_LIST, startWithPublicList) } } } 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 9b40bf53e..f34e2a188 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 @@ -9,6 +9,8 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.annotation.IntegerRes import androidx.core.os.bundleOf +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.firebase.messaging.FirebaseMessaging @@ -67,6 +69,7 @@ class MainActivity : BaseActivity(activity_main) { setupBackButtonListener() setupBottomNavigationView() setupObserver() + setupMyPageTooltip() onViewGuestClick() handleNavigation(intent.getAdaptedSerializableExtra(DESTINATION_KEY)) updateFcmToken() @@ -140,6 +143,9 @@ class MainActivity : BaseActivity(activity_main) { private fun setupBottomNavListener() { binding.bnvMain.setOnItemSelectedListener { item -> fragmentTags[item.itemId] ?: return@setOnItemSelectedListener true + if (item.itemId == menu_my_page && binding.tvMainMyPageTooltip.isVisible) { + mainViewModel.confirmMyPageTooltip() + } val currentFragment = supportFragmentManager.findFragmentById(fcv_main) if (item.itemId == menu_library && currentFragment is LibraryFragment) { @@ -184,6 +190,7 @@ class MainActivity : BaseActivity(activity_main) { } currentFragment = targetFragment + updateMyPageTooltipVisibility() } private fun findOrCreateFragment(tag: String): Fragment = @@ -209,7 +216,30 @@ class MainActivity : BaseActivity(activity_main) { binding.viewMainGuest.visibility = View.VISIBLE } } + updateMyPageTooltipVisibility() + } + } + + private fun setupMyPageTooltip() { + binding.tvMainMyPageTooltip.setOnClickListener { + binding.bnvMain.selectedItemId = menu_my_page } + // 말풍선 꼬리(우측에서 35.5dp)가 마이페이지 탭 중앙을 가리키도록 맞춘다. + binding.tvMainMyPageTooltip.doOnLayout { tooltip -> + val myPageItem = binding.bnvMain.findViewById(menu_my_page) ?: return@doOnLayout + val itemCenterX = binding.bnvMain.left + (myPageItem.parent as View).left + myPageItem.left + myPageItem.width / 2f + val tailX = tooltip.right - TOOLTIP_TAIL_END_OFFSET_DP * resources.displayMetrics.density + tooltip.translationX = itemCenterX - tailX + } + mainViewModel.isMyPageTooltipPending.observe(this) { updateMyPageTooltipVisibility() } + } + + /** 툴팁은 로그인 사용자가 홈 탭에 있을 때만 보여준다. */ + private fun updateMyPageTooltipVisibility() { + binding.tvMainMyPageTooltip.isVisible = + mainViewModel.isMyPageTooltipPending.value == true && + mainViewModel.isLogin.value != false && + currentFragment is HomeFragment } private fun onViewGuestClick() { @@ -258,6 +288,7 @@ class MainActivity : BaseActivity(activity_main) { companion object { private const val DESTINATION_KEY = "destination" + private const val TOOLTIP_TAIL_END_OFFSET_DP = 35.5f const val IS_LOGIN_KEY = "isLogin" fun getIntent(context: Context): Intent = diff --git a/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt index fc3292f3e..c209a0059 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt @@ -30,6 +30,24 @@ class MainViewModel val isLogin: LiveData = savedStateHandle.getLiveData(MainActivity.IS_LOGIN_KEY) + private val _isMyPageTooltipPending: MutableLiveData = MutableLiveData(false) + val isMyPageTooltipPending: LiveData get() = _isMyPageTooltipPending + + init { + viewModelScope.launch { + _isMyPageTooltipPending.value = !userRepository.fetchMyPageTooltipConfirmed() + } + } + + /** 툴팁이 보이는 상태에서 마이페이지로 이동했을 때만 호출한다. 이후로는 툴팁을 띄우지 않는다. */ + fun confirmMyPageTooltip() { + if (_isMyPageTooltipPending.value != true) return + _isMyPageTooltipPending.value = false + viewModelScope.launch { + runCatching { userRepository.saveMyPageTooltipConfirmed() } + } + } + fun updateUserInfo() { viewModelScope.launch { _mainUiState.value = mainUiState.value?.copy( diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index 1318a6a05..5996e0dba 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -8,6 +8,7 @@ import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -26,12 +27,15 @@ import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.common.util.tracker.Tracker +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.home_rising_feed_for_user import com.into.websoso.core.resource.R.string.home_taste_load_failed import com.into.websoso.core.resource.R.string.home_taste_refresh_failed import com.into.websoso.core.resource.R.string.load_load_title import com.into.websoso.databinding.FragmentHomeBinding +import com.into.websoso.feature.collection.HomeCollectionSection +import com.into.websoso.ui.collection.CollectionActivity import com.into.websoso.ui.detailExplore.DetailExploreActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity import com.into.websoso.ui.main.MainViewModel @@ -121,6 +125,7 @@ class HomeFragment : BaseFragment(fragment_home) { binding.btnHomeTasteRetry.setOnClickListener { homeViewModel.retryTaste() } setupObserver() setupDotsIndicator() + setupCollectionSection() onSettingPreferenceGenreClick() onNotificationButtonClick() onNormalSearchButtonClick() @@ -277,6 +282,24 @@ class HomeFragment : BaseFragment(fragment_home) { binding.dotsIndicatorHome.attachTo(binding.vpHomePopularFeed) } + private fun setupCollectionSection() { + binding.cvHomeCollection.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + WebsosoTheme { + HomeCollectionSection( + onListClick = { + startActivity(CollectionActivity.getIntent(requireContext(), startWithPublicList = true)) + }, + onCollectionClick = { + startActivity(CollectionActivity.getIntent(requireContext(), collectionId = it)) + }, + ) + } + } + } + } + private fun onPopularNovelClick(novelId: Long) { tracker.trackEvent("home_today_ranking") navigateToNovelDetail(novelId) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 00d17403c..0e4cd7556 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -47,6 +47,23 @@ app:menu="@menu/menu_main_bnv" tools:menu="@menu/menu_main_bnv" /> + + + + + app:layout_constraintTop_toBottomOf="@id/cv_home_collection" /> + + + + + + diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index b04043d3b..00c0f829f 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ 피드 My 뒤로가기를 한번 더 누르면 종료됩니다 + 나만의 컬렉션을 만들어보세요 작품 @@ -483,6 +484,8 @@ 컬렉션 + 컬렉션 모음.zip + %1$s님의 컬렉션 컬렉션 만들기 등록된 컬렉션이 없어요 컬렉션을 등록하지 않은 유저에요 diff --git a/data/collection/build.gradle.kts b/data/collection/build.gradle.kts index 93557bbfe..79935cfa8 100644 --- a/data/collection/build.gradle.kts +++ b/data/collection/build.gradle.kts @@ -17,4 +17,7 @@ dependencies { implementation(libs.paging.runtime) implementation(libs.retrofit) implementation(libs.serialization.json) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } 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 index d5a1dbe45..d7a86b4b6 100644 --- 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 @@ -14,6 +14,12 @@ internal interface CollectionApi { @Body request: SaveCollectionRequestDto, ): CreateCollectionResponseDto + @GET("collections") + suspend fun getPublicCollections( + @Query("cursor") cursor: String?, + @Query("size") size: Int, + ): PublicCollectionPageResponseDto + @GET("collections/{collectionId}") suspend fun getCollection( @Path("collectionId") collectionId: Long, 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 index e09f89cb8..63f95198e 100644 --- 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 @@ -5,6 +5,7 @@ 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.PublicCollection import com.into.websoso.domain.collection.model.SaveCollection import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -102,6 +103,40 @@ internal data class CollectionOwnerDto( ) } +@Serializable +internal data class PublicCollectionResponseDto( + @SerialName("collectionId") val collectionId: Long, + @SerialName("collectionName") val collectionName: String, + @SerialName("collectionDescription") val collectionDescription: String? = null, + @SerialName("novelCount") val novelCount: Int, + @SerialName("owner") val owner: CollectionOwnerDto, + @SerialName("recentNovels") val recentNovels: List, +) { + fun toDomain() = + PublicCollection( + id = collectionId, + name = collectionName, + description = collectionDescription, + novelCount = novelCount, + owner = owner.toDomain(), + recentNovels = recentNovels.map(CollectionNovelDto::toDomain), + ) +} + +@Serializable +internal data class PublicCollectionPageResponseDto( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("collections") val collections: List, +) { + fun toCursorPage() = + CursorPage( + items = collections.map(PublicCollectionResponseDto::toDomain), + hasNext = hasNext, + nextCursor = nextCursor, + ) +} + @Serializable internal data class CollectionDetailResponseDto( @SerialName("collectionId") val collectionId: Long, 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 index 32bb3e5ac..f7243512a 100644 --- 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 @@ -2,20 +2,27 @@ 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 = +internal data class CursorPage( + val items: List, + val hasNext: Boolean, + val nextCursor: String?, +) + +internal fun CollectionPage.toCursorPage() = CursorPage(items = collections, hasNext = hasNext, nextCursor = nextCursor) + +internal class CollectionPagingSource( + private val getPage: suspend (cursor: String?, size: Int) -> CursorPage, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = runCatching { getPage(params.key, params.loadSize) }.fold( onSuccess = { page -> LoadResult.Page( - data = page.collections, + data = page.items, prevKey = null, nextKey = page.nextCursor.takeIf { page.hasNext }, ) @@ -26,5 +33,5 @@ internal class CollectionPagingSource( }, ) - override fun getRefreshKey(state: PagingState): String? = null + override fun getRefreshKey(state: PagingState): String? = null } 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 5c815dad0..5736184a9 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 @@ -9,6 +9,7 @@ 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.PublicCollection import com.into.websoso.domain.collection.model.SaveCollection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -27,15 +28,20 @@ internal class DefaultCollectionRepository userId = requireUserId(), cursor = cursor, size = size, - ) + ).toCursorPage() } override fun getUserCollections(userId: Long): Flow> = - createPager { cursor, size -> getUserCollectionPage(userId, cursor, size) } + createPager { cursor, size -> getUserCollectionPage(userId, cursor, size).toCursorPage() } override fun getLikedCollections(): Flow> = createPager { cursor, size -> - api.getLikedCollections(cursor, size).toDomain() + api.getLikedCollections(cursor, size).toDomain().toCursorPage() + } + + override fun getPublicCollections(): Flow> = + createPager { cursor, size -> + api.getPublicCollections(cursor, size).toCursorPage() } override suspend fun getMyCollectionPreview(): CollectionPage = @@ -48,6 +54,9 @@ internal class DefaultCollectionRepository override suspend fun getUserCollectionPreview(userId: Long): CollectionPage = getUserCollectionPage(userId = userId, cursor = null, size = PREVIEW_SIZE) + override suspend fun getPublicCollectionPreview(): List = + api.getPublicCollections(cursor = null, size = PUBLIC_PREVIEW_SIZE).toCursorPage().items + override suspend fun getCollection( collectionId: Long, sortCriteria: CollectionSortCriteria, @@ -73,7 +82,7 @@ internal class DefaultCollectionRepository if (isLiked) api.likeCollection(collectionId) else api.unlikeCollection(collectionId) } - private fun createPager(getPage: suspend (cursor: String?, size: Int) -> CollectionPage): Flow> = + private fun createPager(getPage: suspend (cursor: String?, size: Int) -> CursorPage): Flow> = Pager( config = PagingConfig( pageSize = PAGE_SIZE, @@ -99,6 +108,7 @@ internal class DefaultCollectionRepository private companion object { const val PAGE_SIZE = 10 const val PREVIEW_SIZE = 3 + const val PUBLIC_PREVIEW_SIZE = 10 // fetchUserInfo()는 MainActivity.onCreate() 시점에 이미 시작되어 있고 // 정상 응답은 수백 ms 수준이다. 실패하더라도 CollectionPreview에 재시도 버튼이 있어 diff --git a/data/collection/src/test/java/com/into/websoso/data/collection/CollectionPagingSourceTest.kt b/data/collection/src/test/java/com/into/websoso/data/collection/CollectionPagingSourceTest.kt new file mode 100644 index 000000000..2b44c1456 --- /dev/null +++ b/data/collection/src/test/java/com/into/websoso/data/collection/CollectionPagingSourceTest.kt @@ -0,0 +1,51 @@ +package com.into.websoso.data.collection + +import androidx.paging.PagingSource.LoadParams +import androidx.paging.PagingSource.LoadResult +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CollectionPagingSourceTest { + @Test + fun `다음 페이지가 있으면 응답 커서를 다음 키로 쓴다`() = + runTest { + var requestedCursor: String? = "unset" + val source = CollectionPagingSource { cursor, _ -> + requestedCursor = cursor + CursorPage(items = listOf(1, 2), hasNext = true, nextCursor = "next") + } + + val result = source.load(refresh()) as LoadResult.Page + + assertNull(requestedCursor) + assertEquals(listOf(1, 2), result.data) + assertEquals("next", result.nextKey) + } + + @Test + fun `다음 페이지가 없으면 커서가 있어도 다음 키는 null이다`() = + runTest { + val source = CollectionPagingSource { _, _ -> + CursorPage(items = listOf(1), hasNext = false, nextCursor = "ignored") + } + + val result = source.load(refresh()) as LoadResult.Page + + assertNull(result.nextKey) + } + + @Test + fun `요청이 실패하면 에러 결과를 돌려준다`() = + runTest { + val source = CollectionPagingSource { _, _ -> error("network") } + + val result = source.load(refresh()) + + assertTrue(result is LoadResult.Error) + } + + private fun refresh() = LoadParams.Refresh(key = null, loadSize = 10, placeholdersEnabled = false) +} diff --git a/data/collection/src/test/java/com/into/websoso/data/collection/PublicCollectionDtoTest.kt b/data/collection/src/test/java/com/into/websoso/data/collection/PublicCollectionDtoTest.kt new file mode 100644 index 000000000..f7ee43ac5 --- /dev/null +++ b/data/collection/src/test/java/com/into/websoso/data/collection/PublicCollectionDtoTest.kt @@ -0,0 +1,79 @@ +package com.into.websoso.data.collection + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PublicCollectionDtoTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `공개 컬렉션 목록 응답을 작성자와 표지까지 도메인으로 변환한다`() { + val page = json.decodeFromString(FIRST_PAGE).toCursorPage() + + assertTrue(page.hasNext) + assertEquals("cursor-1", page.nextCursor) + val first = page.items.first() + assertEquals(31L, first.id) + assertEquals("취향 저격 로판", first.name) + assertEquals(24, first.novelCount) + assertEquals(7L, first.owner.id) + assertEquals("웹소소", first.owner.nickname) + assertEquals(listOf(9L, 5L), first.recentNovels.map { it.id }) + } + + @Test + fun `설명이 null이고 작품이 하나뿐인 컬렉션도 변환한다`() { + val page = json.decodeFromString(FIRST_PAGE).toCursorPage() + + val second = page.items[1] + assertNull(second.description) + assertEquals(1, second.recentNovels.size) + } + + @Test + fun `마지막 페이지는 nextCursor 없이 내려온다`() { + val page = json + .decodeFromString("""{"hasNext":false,"nextCursor":null,"collections":[]}""") + .toCursorPage() + + assertFalse(page.hasNext) + assertNull(page.nextCursor) + assertTrue(page.items.isEmpty()) + } + + private companion object { + const val FIRST_PAGE = """ + { + "hasNext": true, + "nextCursor": "cursor-1", + "collections": [ + { + "collectionId": 31, + "collectionName": "취향 저격 로판", + "collectionDescription": "여주가 강한 로맨스 판타지 모음", + "novelCount": 24, + "owner": { "userId": 7, "nickname": "웹소소", "avatarImage": "https://image.websoso/avatar/1.png" }, + "recentNovels": [ + { "novelId": 9, "title": "전지적 독자 시점", "novelImage": "https://image.websoso/novel/9.png", "author": "싱숑" }, + { "novelId": 5, "title": "재혼 황후", "novelImage": "https://image.websoso/novel/5.png", "author": "알파타르트" } + ] + }, + { + "collectionId": 12, + "collectionName": "추천 소설", + "collectionDescription": null, + "novelCount": 1, + "owner": { "userId": 45, "nickname": "콜렉터", "avatarImage": "https://image.websoso/avatar/2.png" }, + "recentNovels": [ + { "novelId": 1001, "title": "작품 제목", "novelImage": "https://image.websoso/novel/1001.png", "author": "작가명" } + ] + } + ] + } + """ + } +} 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 index 495c2c496..d929b7231 100644 --- 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 @@ -5,6 +5,7 @@ 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.PublicCollection import com.into.websoso.domain.collection.model.SaveCollection import kotlinx.coroutines.flow.Flow @@ -15,10 +16,14 @@ interface CollectionRepository { fun getLikedCollections(): Flow> + fun getPublicCollections(): Flow> + suspend fun getMyCollectionPreview(): CollectionPage suspend fun getUserCollectionPreview(userId: Long): CollectionPage + suspend fun getPublicCollectionPreview(): List + suspend fun getCollection( collectionId: Long, sortCriteria: CollectionSortCriteria = CollectionSortCriteria.RECENT, 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 index 40c361f12..144050e98 100644 --- 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 @@ -11,6 +11,15 @@ data class Collection( val recentNovels: List, ) +data class PublicCollection( + val id: Long, + val name: String, + val description: String?, + val novelCount: Int, + val owner: CollectionOwner, + val recentNovels: List, +) + data class CollectionNovel( val id: Long, val title: String, 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 1c04bdee3..a9b5accaf 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 @@ -24,6 +24,7 @@ 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 PUBLIC_COLLECTION_ROUTE = "collection/public" 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" @@ -39,10 +40,15 @@ fun CollectionNavHost( isSharing: Boolean = false, initialCollectionId: Long? = null, userId: Long? = null, + startWithPublicList: Boolean = false, ) { val navController = rememberNavController() val arguments = listOf(navArgument("collectionId") { type = NavType.LongType }) - val start = if (initialCollectionId != null) "collection/detail/$initialCollectionId" else COLLECTION_ROUTE + val start = when { + initialCollectionId != null -> "collection/detail/$initialCollectionId" + startWithPublicList -> PUBLIC_COLLECTION_ROUTE + else -> COLLECTION_ROUTE + } val returnToMyList: () -> Unit = { navController.navigate(COLLECTION_ROUTE) { popUpTo(navController.graph.id) { inclusive = false } @@ -91,6 +97,12 @@ fun CollectionNavHost( showMyCollections = showMy, ) } + composable(PUBLIC_COLLECTION_ROUTE) { + PublicCollectionScreen( + onNavigateBack = onNavigateBack, + onCollectionClick = { navController.navigate("collection/detail/$it") }, + ) + } composable(COLLECTION_DETAIL_ROUTE, arguments) { CollectionDetailScreen( onNavigateBack = { if (!navController.popBackStack()) onNavigateBack() }, diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionSection.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionSection.kt new file mode 100644 index 000000000..a476534bc --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionSection.kt @@ -0,0 +1,76 @@ +package com.into.websoso.feature.collection + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +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.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.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R +import com.into.websoso.feature.collection.component.HomeCollectionCard + +/** 홈의 공개 컬렉션 섹션. 보여줄 컬렉션이 없으면 아무것도 그리지 않는다. */ +@Composable +fun HomeCollectionSection( + onListClick: () -> Unit, + onCollectionClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: HomeCollectionViewModel = hiltViewModel() + val collections by viewModel.collections.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh() } + if (collections.isEmpty()) return + + // 아래 섹션('오늘의 발견')의 상단 여백 24dp와 합쳐 섹션 사이 간격 32dp를 맞춘다. + Column( + modifier = modifier.fillMaxWidth().background(White).padding(top = 24.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .debouncedClickable(role = Role.Button, onClick = onListClick) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.collection_public_title), + color = Black, + style = WebsosoTheme.typography.headline1, + modifier = Modifier.weight(1f), + ) + Icon(painterResource(R.drawable.ic_navigate_right), null, tint = Gray300, modifier = Modifier.size(24.dp)) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(collections, key = { it.id }) { collection -> + HomeCollectionCard(collection, onClick = { onCollectionClick(collection.id) }) + } + } + } +} diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionViewModel.kt new file mode 100644 index 000000000..4618ede03 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/HomeCollectionViewModel.kt @@ -0,0 +1,37 @@ +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.PublicCollection +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class HomeCollectionViewModel + @Inject + constructor( + private val repository: CollectionRepository, + ) : ViewModel() { + private val _collections = MutableStateFlow>(emptyList()) + val collections = _collections.asStateFlow() + private var refreshJob: Job? = null + + fun refresh() { + if (refreshJob?.isActive == true) return + refreshJob = viewModelScope.launch { + try { + _collections.value = repository.getPublicCollectionPreview() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // 갱신에 실패하면 이전 목록을 유지한다. 처음부터 실패하면 목록이 비어 섹션이 숨겨진다. + } + } + } + } diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionScreen.kt new file mode 100644 index 000000000..0202b2b45 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionScreen.kt @@ -0,0 +1,91 @@ +package com.into.websoso.feature.collection + +import android.view.HapticFeedbackConstants +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.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +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.CollectionNetworkError +import com.into.websoso.feature.collection.component.PublicCollectionCard + +@Composable +internal fun PublicCollectionScreen( + onNavigateBack: () -> Unit, + onCollectionClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: PublicCollectionViewModel = hiltViewModel() + val collections = viewModel.collections.collectAsLazyPagingItems() + val view = LocalView.current + + Column(modifier.fillMaxSize().background(White).statusBarsPadding()) { + CollectionAppBar( + title = stringResource(R.string.collection_public_title), + onNavigateBack = onNavigateBack, + ) + PullToRefreshBox( + isRefreshing = collections.loadState.refresh is LoadState.Loading && collections.itemCount > 0, + onRefresh = { + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + collections.refresh() + }, + modifier = Modifier.weight(1f), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(count = collections.itemCount, key = collections.itemKey { it.id }) { index -> + collections[index]?.let { collection -> + PublicCollectionCard(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 -> CollectionEmpty( + message = stringResource(R.string.collection_empty), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionViewModel.kt new file mode 100644 index 000000000..2f9f406fe --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/PublicCollectionViewModel.kt @@ -0,0 +1,17 @@ +package com.into.websoso.feature.collection + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import com.into.websoso.domain.collection.CollectionRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class PublicCollectionViewModel + @Inject + constructor( + repository: CollectionRepository, + ) : ViewModel() { + val collections = repository.getPublicCollections().cachedIn(viewModelScope) + } 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 index fbabc245b..667f381bf 100644 --- 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 @@ -5,12 +5,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope 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.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -25,13 +27,16 @@ 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.TextStyle 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.unit.dp import androidx.compose.ui.zIndex import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.component.NetworkImage +import com.into.websoso.core.designsystem.component.S3Image import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Primary100 @@ -39,81 +44,186 @@ 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 +import com.into.websoso.domain.collection.model.CollectionNovel +import com.into.websoso.domain.collection.model.CollectionOwner +import com.into.websoso.domain.collection.model.PublicCollection + +private const val LIST_COVER_COUNT = 5 +private const val HOME_COVER_COUNT = 4 @Composable internal fun CollectionCard( collection: Collection, onClick: () -> Unit, modifier: Modifier = Modifier, +) { + CollectionCardContainer(modifier.fillMaxWidth()) { + CollectionCardHeader( + name = collection.name, + description = collection.description, + novelCount = collection.novelCount, + modifier = Modifier.debouncedClickable(role = Role.Button, onClick = onClick), + ) + CollectionCovers(collection.recentNovels, LIST_COVER_COUNT) + 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) + } + } + } +} + +/** 전체 컬렉션뷰 카드. 프로필 영역을 포함해 카드 어디를 눌러도 상세로 이동한다. */ +@Composable +internal fun PublicCollectionCard( + collection: PublicCollection, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CollectionCardContainer( + modifier.fillMaxWidth().debouncedClickable(role = Role.Button, onClick = onClick), + ) { + CollectionCardHeader(collection.name, collection.description, collection.novelCount) + CollectionCovers(collection.recentNovels, LIST_COVER_COUNT) + CollectionOwnerLabel(collection.owner) + } +} + +/** 홈 컬렉션 섹션 카드. 표지는 최대 4개까지 보여준다. */ +@Composable +internal fun HomeCollectionCard( + collection: PublicCollection, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CollectionCardContainer( + modifier = modifier.width(284.dp).debouncedClickable(role = Role.Button, onClick = onClick), + horizontalPadding = 16.dp, + spacing = 12.dp, + ) { + CollectionCardHeader( + name = collection.name, + description = collection.description, + novelCount = collection.novelCount, + showArrow = false, + descriptionStyle = WebsosoTheme.typography.label1, + ) + CollectionCovers(collection.recentNovels, HOME_COVER_COUNT) + // 표지~프로필은 16dp라 카드 간격(12dp)에 4dp를 더한다. + CollectionOwnerLabel(collection.owner, Modifier.padding(top = 4.dp)) + } +} + +@Composable +private fun CollectionCardContainer( + modifier: Modifier = Modifier, + horizontalPadding: Dp = 17.dp, + spacing: Dp = 16.dp, + content: @Composable ColumnScope.() -> Unit, ) { 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().debouncedClickable(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)) - } + .padding(horizontal = horizontalPadding, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(spacing), + content = content, + ) +} + +@Composable +private fun CollectionCardHeader( + name: String, + description: String?, + novelCount: Int, + modifier: Modifier = Modifier, + showArrow: Boolean = true, + descriptionStyle: TextStyle = WebsosoTheme.typography.label2, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { 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()) } - }, + text = name.ellipsize(15), color = Gray300, - style = WebsosoTheme.typography.label2, + style = WebsosoTheme.typography.title2, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) - } - 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 (showArrow) { + Icon(painterResource(R.drawable.ic_navigate_right), null, tint = Gray300, modifier = Modifier.size(24.dp)) } } - 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) + Text( + text = buildAnnotatedString { + description?.takeIf(String::isNotBlank)?.let { append("${it.ellipsize(20)} · ") } + append(stringResource(R.string.collection_novels_label)) + append(" ") + withStyle(SpanStyle(color = Primary100)) { append(novelCount.toString()) } + }, + color = Gray300, + style = descriptionStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** 표지를 겹쳐 [coverCount]칸 보여주고, 작품이 모자라면 빈 표지로 채운다. */ +@Composable +private fun CollectionCovers( + novels: List, + coverCount: Int, +) { + BoxWithConstraints(Modifier.fillMaxWidth().height(108.dp).clipToBounds()) { + val coverWidth = 74.dp + val step = ((maxWidth - coverWidth) / (coverCount - 1)).coerceAtLeast(0.dp) + repeat(coverCount) { index -> + val novel = novels.getOrNull(index) + val coverModifier = Modifier + .offset(x = step * index) + .size(coverWidth, 108.dp) + .zIndex((coverCount - 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, + ) } } } } +@Composable +private fun CollectionOwnerLabel( + owner: CollectionOwner, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + S3Image( + imageUrl = owner.avatarImageUrl, + modifier = Modifier.size(16.dp).clip(RoundedCornerShape(5.dp)), + ) + Text( + text = stringResource(R.string.collection_owner_label, owner.nickname), + color = Gray200, + style = WebsosoTheme.typography.label2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + internal fun String.ellipsize(limit: Int): String = if (length > limit) take(limit) + "…" else this