Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemTags>

@GET("{account_id}/api/v1/shopkeeper/item_tags/{id}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface ItemTagRepository {
fun getItemTags(
shopId: String,
page: Int? = null,
): Flow<ItemTags>

fun getItemTag(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -172,7 +189,8 @@ private fun ItemTagListContentView(
.padding(padding),
) {
LazyColumn(
Modifier.padding(24.dp),
state = listState,
modifier = Modifier.padding(24.dp),
) {
item {
Text(
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,12 +27,18 @@ import javax.inject.Inject

data class ItemTagListUiState(
val shop: Shop = Shop(),
val itemTags: ItemTags = ItemTags(),
val itemTags: List<Data> = 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
Expand All @@ -47,39 +54,62 @@ class ItemTagListViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ItemTagListUiState())
val uiState: StateFlow<ItemTagListUiState> = _uiState.asStateFlow()

private var fetchJob: Job? = null

fun reload() {
fetchData()
fetchData(page = 1, isReload = true)
}

fun isEmpty(): StateFlow<Boolean> = uiState.map { it.itemTags.datum.isEmpty() }
fun isEmpty(): StateFlow<Boolean> = 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<Shop> = shopRepository.getShop(shopId)
val itemTagsFlow: Flow<ItemTags> = 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,
)
Expand All @@ -90,6 +120,7 @@ class ItemTagListViewModel @Inject constructor(
it.copy(
message = message,
isLoading = false,
isLoadingMore = false,
)
}
}.collect {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class DemoItemTagRepository @Inject constructor(
emit(itemTag)
}.flowOn(ioDispatcher)

override fun getItemTags(shopId: String): Flow<ItemTags> = itemTagsFlow
override fun getItemTags(shopId: String, page: Int?): Flow<ItemTags> = itemTagsFlow

override fun getItemTag(id: String): Flow<ItemTag> = itemTagFlow

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemTags> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var currentItemTags: ItemTags = ItemTags()

private val itemTagFlow: MutableSharedFlow<ItemTag> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)

override fun getItemTags(shopId: String): Flow<ItemTags> = itemTagsFlow
override fun getItemTags(shopId: String, page: Int?): Flow<ItemTags> = flow { emit(currentItemTags) }

override fun getItemTag(id: String): Flow<ItemTag> = itemTagFlow

Expand All @@ -34,7 +34,7 @@ class TestItemTagRepository : ItemTagRepository {
* A test-only API.
*/
fun sendItemTags(itemTags: ItemTags) {
itemTagsFlow.tryEmit(itemTags)
currentItemTags = itemTags
}

fun sendItemTag(itemTag: ItemTag) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading