From 33648cef0e81d9ad37a0773ab393bffd1aa3bab5 Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 3 Apr 2026 17:35:54 +0900 Subject: [PATCH] Implement pagination for item tags list screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add page query parameter to getItemTags() API call, propagated through repository layer. ItemTagListViewModel accumulates pages with loadMore() for infinite scroll. ItemTagListView detects scroll-near-bottom to trigger loading the next page. ShopDetailViewModel unchanged — calls without page param for backward-compat. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data/item_tag/ItemTagApi.kt | 1 + .../data/item_tag/ItemTagRepository.kt | 1 + .../data/item_tag/ItemTagRepositoryImpl.kt | 2 + .../item_tag_list/ItemTagListView.kt | 34 +++++++- .../item_tag_list/ItemTagListViewModel.kt | 59 ++++++++++--- .../demo/item_tag/DemoItemTagRepository.kt | 2 +- .../repository/TestItemTagRepository.kt | 8 +- .../item_tag_list/ItemTagListViewModelTest.kt | 85 ++++++++++++++++++- .../nativeapptemplatefree/model/Meta.kt | 11 +++ 9 files changed, 181 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt index 76f5e48..b5ea5d8 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt @@ -10,6 +10,7 @@ interface ItemTagApi { suspend fun getItemTags( @Path("account_id") accountId: String, @Path("shop_id") shopId: String, + @Query("page") page: Int? = null, ): ApiResponse @GET("{account_id}/api/v1/shopkeeper/item_tags/{id}") diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt index d33b1b0..82d1649 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow interface ItemTagRepository { fun getItemTags( shopId: String, + page: Int? = null, ): Flow fun getItemTag( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt index ceb9ff0..002e5be 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt @@ -19,10 +19,12 @@ class ItemTagRepositoryImpl @Inject constructor( override fun getItemTags( shopId: String, + page: Int?, ) = flow { val response = api.getItemTags( mtcPreferencesDataSource.userData.first().accountId, shopId, + page, ) emitApiResponse(response) }.flowOn(ioDispatcher) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt index 2ee11a2..4c73822 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt @@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Rectangle import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -31,7 +33,10 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -132,7 +137,19 @@ private fun ItemTagListContentView( onBackClick: () -> Unit, ) { val isEmpty: Boolean by viewModel.isEmpty().collectAsStateWithLifecycle() - val itemTags = uiState.itemTags.getDatumWithRelationships().toMutableList() + val itemTags = uiState.itemTags.toMutableList() + val listState = rememberLazyListState() + + val prefetchDistance = 3 + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisibleIndex >= itemTags.size - prefetchDistance && uiState.hasMorePages && !uiState.isLoadingMore + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) viewModel.loadMore() + } Scaffold( topBar = { @@ -172,7 +189,8 @@ private fun ItemTagListContentView( .padding(padding), ) { LazyColumn( - Modifier.padding(24.dp), + state = listState, + modifier = Modifier.padding(24.dp), ) { item { Text( @@ -212,6 +230,18 @@ private fun ItemTagListContentView( } HorizontalDivider() } + if (uiState.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } } Indicator( modifier = Modifier.align(Alignment.TopCenter), diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt index 3d65999..e36b0d3 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt @@ -7,10 +7,11 @@ import androidx.navigation.toRoute import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository -import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.Shop import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagListRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -26,12 +27,18 @@ import javax.inject.Inject data class ItemTagListUiState( val shop: Shop = Shop(), - val itemTags: ItemTags = ItemTags(), + val itemTags: List = emptyList(), val isLoading: Boolean = true, val success: Boolean = false, val message: String = "", -) + + val currentPage: Int = 1, + val totalPages: Int = 1, + val isLoadingMore: Boolean = false, +) { + val hasMorePages: Boolean get() = currentPage < totalPages +} /** * ViewModel for library view @@ -47,39 +54,62 @@ class ItemTagListViewModel @Inject constructor( private val _uiState = MutableStateFlow(ItemTagListUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var fetchJob: Job? = null + fun reload() { - fetchData() + fetchData(page = 1, isReload = true) } - fun isEmpty(): StateFlow = uiState.map { it.itemTags.datum.isEmpty() } + fun isEmpty(): StateFlow = uiState.map { it.itemTags.isEmpty() } .stateIn( scope = viewModelScope, initialValue = false, started = SharingStarted.WhileSubscribed(5_000), ) - private fun fetchData() { + fun loadMore() { + val state = _uiState.value + if (state.isLoadingMore || !state.hasMorePages) return + fetchData(page = state.currentPage + 1, isReload = false) + } + + private fun fetchData(page: Int, isReload: Boolean) { val shopId = shopId - _uiState.update { - it.copy( - isLoading = true, - success = false, - ) + if (isReload) { + _uiState.update { + it.copy( + isLoading = true, + success = false, + ) + } + } else { + _uiState.update { + it.copy( + isLoadingMore = true, + ) + } } - viewModelScope.launch { + fetchJob?.cancel() + fetchJob = viewModelScope.launch { val shopFlow: Flow = shopRepository.getShop(shopId) - val itemTagsFlow: Flow = itemTagRepository.getItemTags(shopId) + val itemTagsFlow = itemTagRepository.getItemTags(shopId, page) combine( shopFlow, itemTagsFlow, ) { shop, itemTags -> + val newItems = itemTags.getDatumWithRelationships() + val allItems = if (isReload) newItems else _uiState.value.itemTags + newItems + _uiState.update { it.copy( shop = shop, - itemTags = itemTags, + itemTags = allItems, + currentPage = itemTags.meta?.currentPage ?: page, + totalPages = itemTags.meta?.totalPages ?: 1, + isLoadingMore = false, success = true, isLoading = false, ) @@ -90,6 +120,7 @@ class ItemTagListViewModel @Inject constructor( it.copy( message = message, isLoading = false, + isLoadingMore = false, ) } }.collect { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt index 228fa50..0759716 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt @@ -36,7 +36,7 @@ class DemoItemTagRepository @Inject constructor( emit(itemTag) }.flowOn(ioDispatcher) - override fun getItemTags(shopId: String): Flow = itemTagsFlow + override fun getItemTags(shopId: String, page: Int?): Flow = itemTagsFlow override fun getItemTag(id: String): Flow = itemTagFlow diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt index a0ef63e..5f70abe 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt @@ -8,15 +8,15 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow class TestItemTagRepository : ItemTagRepository { - private val itemTagsFlow: MutableSharedFlow = - MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var currentItemTags: ItemTags = ItemTags() private val itemTagFlow: MutableSharedFlow = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - override fun getItemTags(shopId: String): Flow = itemTagsFlow + override fun getItemTags(shopId: String, page: Int?): Flow = flow { emit(currentItemTags) } override fun getItemTag(id: String): Flow = itemTagFlow @@ -34,7 +34,7 @@ class TestItemTagRepository : ItemTagRepository { * A test-only API. */ fun sendItemTags(itemTags: ItemTags) { - itemTagsFlow.tryEmit(itemTags) + currentItemTags = itemTags } fun sendItemTag(itemTag: ItemTag) { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt index b56593f..ef48017 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt @@ -6,6 +6,7 @@ import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import com.nativeapptemplate.nativeapptemplatefree.model.Meta import com.nativeapptemplate.nativeapptemplatefree.model.Shop import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository @@ -88,7 +89,7 @@ class ItemTagListViewModelTest { val itemTagsFromRepository = itemTagRepository.getItemTags(shopId).first() assertEquals(shopFromRepository, uiStateValue.shop) - assertEquals(itemTagsFromRepository, uiStateValue.itemTags) + assertEquals(itemTagsFromRepository.getDatumWithRelationships(), uiStateValue.itemTags) } @Test @@ -105,6 +106,78 @@ class ItemTagListViewModelTest { val uiStateValue = viewModel.uiState.value assertFalse(uiStateValue.isLoading) } + + @Test + fun paginationMeta_whenPresent_isReflectedInUiState() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTagsPage1) + + viewModel.reload() + + val uiStateValue = viewModel.uiState.value + assertEquals(1, uiStateValue.currentPage) + assertEquals(2, uiStateValue.totalPages) + assertTrue(uiStateValue.hasMorePages) + } + + @Test + fun loadMore_accumulatesItemsFromNextPage() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTagsPage1) + + viewModel.reload() + assertEquals(2, viewModel.uiState.value.itemTags.size) + + itemTagRepository.sendItemTags(testInputItemTagsPage2) + viewModel.loadMore() + + val uiStateValue = viewModel.uiState.value + assertEquals(3, uiStateValue.itemTags.size) + assertEquals(2, uiStateValue.currentPage) + assertEquals(2, uiStateValue.totalPages) + assertFalse(uiStateValue.hasMorePages) + } + + @Test + fun loadMore_doesNotFire_whenNoMorePages() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTagsPage2) + + viewModel.reload() + + val itemCountBefore = viewModel.uiState.value.itemTags.size + viewModel.loadMore() + assertEquals(itemCountBefore, viewModel.uiState.value.itemTags.size) + assertFalse(viewModel.uiState.value.isLoadingMore) + } + + @Test + fun reload_resetsToFirstPage() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTagsPage1) + + viewModel.reload() + assertEquals(2, viewModel.uiState.value.itemTags.size) + + itemTagRepository.sendItemTags(testInputItemTagsPage2) + viewModel.loadMore() + assertEquals(3, viewModel.uiState.value.itemTags.size) + + itemTagRepository.sendItemTags(testInputItemTagsPage1) + viewModel.reload() + + val uiStateValue = viewModel.uiState.value + assertEquals(2, uiStateValue.itemTags.size) + assertEquals(1, uiStateValue.currentPage) + } } private const val SHOP_TYPE = "shop" @@ -197,3 +270,13 @@ private val testInputItemTags = ItemTags( private val testInputItemTag = ItemTag( datum = testInputItemTagsData.first(), ) + +private val testInputItemTagsPage1 = ItemTags( + datum = listOf(testInputItemTagsData[0], testInputItemTagsData[1]), + meta = Meta(currentPage = 1, totalPages = 2, totalCount = 3, limit = 2), +) + +private val testInputItemTagsPage2 = ItemTags( + datum = listOf(testInputItemTagsData[2]), + meta = Meta(currentPage = 2, totalPages = 2, totalCount = 3, limit = 2), +) diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt index 4d0d18b..46f7f89 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt @@ -30,4 +30,15 @@ data class Meta( var shopLimitCount: Int = 0, var count: Int = 0, + + @SerialName("current_page") + val currentPage: Int? = null, + + @SerialName("total_pages") + val totalPages: Int? = null, + + @SerialName("total_count") + val totalCount: Int? = null, + + val limit: Int? = null, ) : Parcelable