From 42af876b01ac6dce8685e01f6edb441731f79c27 Mon Sep 17 00:00:00 2001 From: Alexius Andrianno Alfa Renanta Date: Mon, 19 Jan 2026 12:37:48 +0700 Subject: [PATCH 1/2] feat(paging): implement paging and remote mediator for Fruittie list and add RemoteKeys support --- .../fruitties/android/ui/ListScreen.kt | 54 ++++++++++-- Fruitties/iosApp/iosApp/ui/ContentView.swift | 5 +- .../example/fruitties/di/Factory.android.kt | 1 + .../com/example/fruitties/DataRepository.kt | 33 +++---- .../example/fruitties/database/AppDatabase.kt | 5 +- .../example/fruitties/database/FruittieDao.kt | 7 ++ .../fruitties/database/RemoteKeysDao.kt | 19 ++++ .../com/example/fruitties/model/Fruittie.kt | 2 +- .../com/example/fruitties/model/RemoteKeys.kt | 15 ++++ .../paging/FruittieRemoteMediator.kt | 87 +++++++++++++++++++ .../fruitties/viewmodel/MainViewModel.kt | 14 +-- .../example/fruitties/di/Factory.native.kt | 1 + 12 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/RemoteKeysDao.kt create mode 100644 Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/RemoteKeys.kt create mode 100644 Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt index ee1f5ad..00a0e2d 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt @@ -18,6 +18,7 @@ package com.example.fruitties.android.ui 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 @@ -27,10 +28,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -46,10 +47,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import com.example.fruitties.android.LocalAppContainer import com.example.fruitties.android.R import com.example.fruitties.model.Fruittie @@ -64,6 +69,7 @@ fun ListScreen( factory = LocalAppContainer.current.mainViewModelFactory, ), ) { + val lazyPagingItems = viewModel.fruittiesPagingData.collectAsLazyPagingItems() val uiState by viewModel.homeUiState.collectAsState() val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( @@ -102,13 +108,45 @@ fun ListScreen( .padding(paddingValues) .consumeWindowInsets(paddingValues), ) { - items(items = uiState.fruitties, key = { it.id }) { item -> - FruittieItem( - item = item, - onClick = onFruittieClick, - onAddToCart = viewModel::addItemToCart, - modifier = Modifier.fillMaxWidth(), - ) + items( + count = lazyPagingItems.itemCount, + key = lazyPagingItems.itemKey { it.id } + ) { index -> + val item = lazyPagingItems[index] + if (item != null) { + FruittieItem( + item = item, + onClick = onFruittieClick, + onAddToCart = viewModel::addItemToCart, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + when (lazyPagingItems.loadState.append) { + is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + is LoadState.Error -> { + Text( + text = "Error loading more items", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } } } } diff --git a/Fruitties/iosApp/iosApp/ui/ContentView.swift b/Fruitties/iosApp/iosApp/ui/ContentView.swift index c92a4d8..6e809e3 100644 --- a/Fruitties/iosApp/iosApp/ui/ContentView.swift +++ b/Fruitties/iosApp/iosApp/ui/ContentView.swift @@ -33,10 +33,9 @@ struct ContentView: View { factory: appContainer.value.mainViewModelFactory ) NavigationStack { - Observing(mainViewModel.homeUiState) { homeUIState in + Observing(mainViewModel.fruittiesPagingData) { pagingData in List { - ForEach(homeUIState.fruitties, id: \.self) { - value in + ForEach(pagingData, id: \.self) { value in HStack { NavigationLink { FruittieScreen(fruittie: value) diff --git a/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt b/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt index 9c86774..f5cafd1 100644 --- a/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt +++ b/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt @@ -34,6 +34,7 @@ actual class Factory( name = dbFile.absolutePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(dropAllTables = true) .build() } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt index abd9a5b..57b3b4f 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt @@ -15,17 +15,21 @@ */ package com.example.fruitties +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.example.fruitties.database.AppDatabase import com.example.fruitties.database.CartDataStore import com.example.fruitties.model.CartItemDetails import com.example.fruitties.model.Fruittie import com.example.fruitties.network.FruittieApi +import com.example.fruitties.paging.FruittieRemoteMediator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch class DataRepository( private val api: FruittieApi, @@ -53,14 +57,18 @@ class DataRepository( cartDataStore.remove(fruittie) } - fun getData(): Flow> { - scope.launch { - if (database.fruittieDao().count() < 1) { - refreshData() - } - } - return loadData() - } + @OptIn(ExperimentalPagingApi::class) + fun getPagingData(): Flow> = + Pager( + config = PagingConfig( + pageSize = 20, + prefetchDistance = 10, + enablePlaceholders = false, + initialLoadSize = 20 + ), + remoteMediator = FruittieRemoteMediator(api, database), + pagingSourceFactory = { database.fruittieDao().pagingSource() }, + ).flow suspend fun getFruittie(id: Long): Fruittie? = database.fruittieDao().getFruittie(id) @@ -68,11 +76,4 @@ class DataRepository( cartDataStore.cart.map { cart -> cart.items.find { it.id == id }?.count ?: 0 } - - fun loadData(): Flow> = database.fruittieDao().getAllAsFlow() - - suspend fun refreshData() { - val response = api.getData() - database.fruittieDao().insert(response.feed) - } } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/AppDatabase.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/AppDatabase.kt index 1182dfb..46fd0a1 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/AppDatabase.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/AppDatabase.kt @@ -20,11 +20,14 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import com.example.fruitties.model.Fruittie +import com.example.fruitties.model.RemoteKeys -@Database(entities = [Fruittie::class], version = 1) +@Database(entities = [Fruittie::class, RemoteKeys::class], version = 2) @ConstructedBy(AppDatabaseConstructor::class) abstract class AppDatabase : RoomDatabase() { abstract fun fruittieDao(): FruittieDao + + abstract fun remoteKeysDao(): RemoteKeysDao } // The Room compiler generates the `actual` implementations. diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt index 2e15e79..e0da5e1 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt @@ -15,6 +15,7 @@ */ package com.example.fruitties.database +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.MapColumn @@ -31,9 +32,15 @@ interface FruittieDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(fruitties: List) + @Query("SELECT * FROM Fruittie ORDER BY id DESC") + fun pagingSource(): PagingSource + @Query("SELECT * FROM Fruittie") fun getAllAsFlow(): Flow> + @Query("DELETE FROM Fruittie") + suspend fun clearAll() + @Query("SELECT COUNT(*) as count FROM Fruittie") suspend fun count(): Int diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/RemoteKeysDao.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/RemoteKeysDao.kt new file mode 100644 index 0000000..5914fdf --- /dev/null +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/RemoteKeysDao.kt @@ -0,0 +1,19 @@ +package com.example.fruitties.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.fruitties.model.RemoteKeys + +@Dao +interface RemoteKeysDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(remoteKey: List) + + @Query("SELECT * FROM remote_keys WHERE fruittieId = :id") + suspend fun getRemoteKeyByFruittieId(id: Long): RemoteKeys? + + @Query("DELETE FROM remote_keys") + suspend fun clearRemoteKeys() +} diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/Fruittie.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/Fruittie.kt index 6cd5bf9..be712c9 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/Fruittie.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/Fruittie.kt @@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable @Serializable @Entity data class Fruittie( - @PrimaryKey(autoGenerate = true) val id: Long = 0, + @PrimaryKey val id: Long = 0, @SerialName("name") val name: String, @SerialName("full_name") diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/RemoteKeys.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/RemoteKeys.kt new file mode 100644 index 0000000..1dec889 --- /dev/null +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/model/RemoteKeys.kt @@ -0,0 +1,15 @@ +package com.example.fruitties.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Entity(tableName = "remote_keys") +data class RemoteKeys @OptIn(ExperimentalTime::class) constructor( + @PrimaryKey + val fruittieId: Long, + val prevKey: Int?, + val nextKey: Int?, + val createdAt: Long = Clock.System.now().toEpochMilliseconds() +) diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt new file mode 100644 index 0000000..c78878a --- /dev/null +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt @@ -0,0 +1,87 @@ +package com.example.fruitties.paging + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.example.fruitties.database.AppDatabase +import com.example.fruitties.model.Fruittie +import com.example.fruitties.model.RemoteKeys +import com.example.fruitties.network.FruittieApi + +@OptIn(ExperimentalPagingApi::class) +class FruittieRemoteMediator( + private val api: FruittieApi, + private val database: AppDatabase, +) : RemoteMediator() { + private val fruittieDao = database.fruittieDao() + private val remoteKeysDao = database.remoteKeysDao() + + override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + return try { + val page = when (loadType) { + LoadType.REFRESH -> { + val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) + remoteKeys?.nextKey?.minus(1) ?: 0 + } + LoadType.PREPEND -> { + val remoteKeys = getRemoteKeyForFirstItem(state) + val prevKey = remoteKeys?.prevKey + ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) + prevKey + } + LoadType.APPEND -> { + val remoteKeys = getRemoteKeyForLastItem(state) + val nextKey = remoteKeys?.nextKey + ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) + nextKey + } + } + + val response = api.getData(page) + val fruitties = response.feed + val endOfPaginationReached = fruitties.isEmpty() || page >= response.totalPages - 1 + + if (loadType == LoadType.REFRESH) { + remoteKeysDao.clearRemoteKeys() + fruittieDao.clearAll() + } + + val prevKey = if (page == 0) null else page - 1 + val nextKey = if (endOfPaginationReached) null else page + 1 + val keys = fruitties.map { + RemoteKeys( + fruittieId = it.id, + prevKey = prevKey, + nextKey = nextKey, + ) + } + remoteKeysDao.insertAll(keys) + fruittieDao.insert(fruitties) + + MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState): RemoteKeys? = + state.anchorPosition?.let { position -> + state.closestItemToPosition(position)?.id?.let { id -> + remoteKeysDao.getRemoteKeyByFruittieId(id) + } + } + + private suspend fun getRemoteKeyForFirstItem(state: PagingState): RemoteKeys? = + state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()?.let { fruittie -> + remoteKeysDao.getRemoteKeyByFruittieId(fruittie.id) + } + + private suspend fun getRemoteKeyForLastItem(state: PagingState): RemoteKeys? = + state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull()?.let { fruittie -> + remoteKeysDao.getRemoteKeyByFruittieId(fruittie.id) + } +} diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt index a97cf43..9f50ad4 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt @@ -18,12 +18,15 @@ package com.example.fruitties.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import co.touchlab.kermit.Logger import com.example.fruitties.DataRepository import com.example.fruitties.model.Fruittie +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -39,12 +42,14 @@ class MainViewModel( Logger.v { "MainViewModel cleared" } } + val fruittiesPagingData: Flow> = + repository.getPagingData().cachedIn(viewModelScope) + val homeUiState: StateFlow = repository - .getData() - .combine(repository.cartDetails) { fruitties, cartState -> + .cartDetails + .map { cartState -> HomeUiState( - fruitties = fruitties, cartItemCount = cartState.sumOf { item -> item.count }, ) }.stateIn( @@ -64,7 +69,6 @@ class MainViewModel( * Ui State for the home screen */ data class HomeUiState( - val fruitties: List = listOf(), val cartItemCount: Int = 0, ) diff --git a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt index db35496..a63a4e5 100644 --- a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt +++ b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt @@ -36,6 +36,7 @@ actual class Factory { name = dbFile, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(dropAllTables = true) .build() } From abb08fd960730aaf61d3fb2c8a67c01e92bf26e5 Mon Sep 17 00:00:00 2001 From: Alexius Andrianno Alfa Renanta Date: Mon, 19 Jan 2026 17:31:18 +0700 Subject: [PATCH 2/2] feat(paging): enhance error handling and loading indicators in the Fruittie list --- .../fruitties/android/ui/ErrorStateScreen.kt | 38 ++++++ .../fruitties/android/ui/ListScreen.kt | 124 +++++++++++------- .../fruitties/android/ui/LoadingIndicator.kt | 20 +++ .../src/main/res/values/strings.xml | 3 + .../example/fruitties/di/Factory.android.kt | 3 +- .../com/example/fruitties/DataRepository.kt | 12 +- .../example/fruitties/database/Migrations.kt | 18 +++ .../paging/FruittieRemoteMediator.kt | 20 ++- .../example/fruitties/di/Factory.native.kt | 3 +- 9 files changed, 182 insertions(+), 59 deletions(-) create mode 100644 Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ErrorStateScreen.kt create mode 100644 Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/LoadingIndicator.kt create mode 100644 Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/Migrations.kt diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ErrorStateScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ErrorStateScreen.kt new file mode 100644 index 0000000..931a7cb --- /dev/null +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ErrorStateScreen.kt @@ -0,0 +1,38 @@ +package com.example.fruitties.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.fruitties.android.R + +@Composable +fun ErrorStateItem( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } +} diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt index 00a0e2d..221be73 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt @@ -18,12 +18,12 @@ package com.example.fruitties.android.ui 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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -40,6 +39,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -47,12 +47,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.example.fruitties.android.LocalAppContainer @@ -100,53 +100,83 @@ fun ListScreen( } }, ) { paddingValues -> - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 72.dp), - modifier = Modifier - .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) - .padding(paddingValues) - .consumeWindowInsets(paddingValues), - ) { - items( - count = lazyPagingItems.itemCount, - key = lazyPagingItems.itemKey { it.id } - ) { index -> - val item = lazyPagingItems[index] - if (item != null) { - FruittieItem( - item = item, - onClick = onFruittieClick, - onAddToCart = viewModel::addItemToCart, - modifier = Modifier.fillMaxWidth(), - ) - } + when (lazyPagingItems.loadState.refresh) { + is LoadState.Loading -> { + LoadingIndicator( + modifier = Modifier + .padding(paddingValues) + .fillMaxHeight() + .consumeWindowInsets(paddingValues), + ) + } + is LoadState.Error -> { + ErrorStateItem( + message = stringResource(R.string.error_loading), + onRetry = { lazyPagingItems.refresh() }, + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .padding(16.dp), + ) + } + else -> { + PagingList( + lazyPagingItems = lazyPagingItems, + paddingValues = paddingValues, + topAppBarScrollBehavior = topAppBarScrollBehavior, + onFruittieClick = onFruittieClick, + onAddToCart = viewModel::addItemToCart, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PagingList( + lazyPagingItems: LazyPagingItems, + paddingValues: PaddingValues, + topAppBarScrollBehavior: TopAppBarScrollBehavior, + onFruittieClick: (Fruittie) -> Unit, + onAddToCart: (Fruittie) -> Unit, +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = 72.dp), + modifier = Modifier + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + ) { + items( + count = lazyPagingItems.itemCount, + key = lazyPagingItems.itemKey { it.id }, + ) { index -> + val item = lazyPagingItems[index] + if (item != null) { + FruittieItem( + item = item, + onClick = onFruittieClick, + onAddToCart = onAddToCart, + modifier = Modifier.fillMaxWidth(), + ) } + } - item { - when (lazyPagingItems.loadState.append) { - is LoadState.Loading -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - is LoadState.Error -> { - Text( - text = "Error loading more items", - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.error, - ) - } - else -> {} + item { + when (lazyPagingItems.loadState.append) { + is LoadState.Loading -> { + LoadingIndicator(modifier = Modifier.padding(16.dp)) + } + is LoadState.Error -> { + ErrorStateItem( + message = stringResource(R.string.error_loading_more), + onRetry = { lazyPagingItems.retry() }, + modifier = Modifier.padding(16.dp), + ) } + else -> {} } } } diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/LoadingIndicator.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/LoadingIndicator.kt new file mode 100644 index 0000000..0736112 --- /dev/null +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/LoadingIndicator.kt @@ -0,0 +1,20 @@ +package com.example.fruitties.android.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} diff --git a/Fruitties/androidApp/src/main/res/values/strings.xml b/Fruitties/androidApp/src/main/res/values/strings.xml index 7f615a2..ede77fb 100644 --- a/Fruitties/androidApp/src/main/res/values/strings.xml +++ b/Fruitties/androidApp/src/main/res/values/strings.xml @@ -24,4 +24,7 @@ Add to cart In cart: %1$d Cart has %1$d items + Failed to load items. Please check your connection and try again. + Failed to load more items + Retry \ No newline at end of file diff --git a/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt b/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt index f5cafd1..187fb2c 100644 --- a/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt +++ b/Fruitties/shared/src/androidMain/kotlin/com/example/fruitties/di/Factory.android.kt @@ -21,6 +21,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.example.fruitties.database.AppDatabase import com.example.fruitties.database.CartDataStore import com.example.fruitties.database.DB_FILE_NAME +import com.example.fruitties.database.MIGRATION_1_2 import kotlinx.coroutines.Dispatchers actual class Factory( @@ -34,7 +35,7 @@ actual class Factory( name = dbFile.absolutePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .fallbackToDestructiveMigration(dropAllTables = true) + .addMigrations(MIGRATION_1_2) .build() } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt index 57b3b4f..39f1869 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt @@ -61,10 +61,10 @@ class DataRepository( fun getPagingData(): Flow> = Pager( config = PagingConfig( - pageSize = 20, - prefetchDistance = 10, + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, enablePlaceholders = false, - initialLoadSize = 20 + initialLoadSize = INITIAL_LOAD_SIZE, ), remoteMediator = FruittieRemoteMediator(api, database), pagingSourceFactory = { database.fruittieDao().pagingSource() }, @@ -76,4 +76,10 @@ class DataRepository( cartDataStore.cart.map { cart -> cart.items.find { it.id == id }?.count ?: 0 } + + companion object { + private const val PAGE_SIZE = 20 + private const val PREFETCH_DISTANCE = 10 + private const val INITIAL_LOAD_SIZE = 20 + } } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/Migrations.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/Migrations.kt new file mode 100644 index 0000000..f0c9e92 --- /dev/null +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/Migrations.kt @@ -0,0 +1,18 @@ +package com.example.fruitties.database + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `remote_keys` (`fruittieId` INTEGER NOT NULL, `prevKey` INTEGER, `nextKey` INTEGER, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`fruittieId`))", + ) + + connection.execSQL("CREATE TABLE IF NOT EXISTS `Fruittie_new` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `fullName` TEXT NOT NULL, `calories` TEXT NOT NULL, PRIMARY KEY(`id`))") + connection.execSQL("INSERT INTO `Fruittie_new` (`id`, `name`, `fullName`, `calories`) SELECT `id`, `name`, `fullName`, `calories` FROM `Fruittie`") + connection.execSQL("DROP TABLE `Fruittie`") + connection.execSQL("ALTER TABLE `Fruittie_new` RENAME TO `Fruittie`") + } +} diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt index c78878a..d3ab8eb 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/paging/FruittieRemoteMediator.kt @@ -4,6 +4,8 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection import com.example.fruitties.database.AppDatabase import com.example.fruitties.model.Fruittie import com.example.fruitties.model.RemoteKeys @@ -45,11 +47,6 @@ class FruittieRemoteMediator( val fruitties = response.feed val endOfPaginationReached = fruitties.isEmpty() || page >= response.totalPages - 1 - if (loadType == LoadType.REFRESH) { - remoteKeysDao.clearRemoteKeys() - fruittieDao.clearAll() - } - val prevKey = if (page == 0) null else page - 1 val nextKey = if (endOfPaginationReached) null else page + 1 val keys = fruitties.map { @@ -59,8 +56,17 @@ class FruittieRemoteMediator( nextKey = nextKey, ) } - remoteKeysDao.insertAll(keys) - fruittieDao.insert(fruitties) + + database.useWriterConnection { + it.immediateTransaction { + if (loadType == LoadType.REFRESH && fruitties.isNotEmpty()) { + remoteKeysDao.clearRemoteKeys() + fruittieDao.clearAll() + } + remoteKeysDao.insertAll(keys) + fruittieDao.insert(fruitties) + } + } MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) } catch (e: Exception) { diff --git a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt index a63a4e5..7aee8af 100644 --- a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt +++ b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/Factory.native.kt @@ -20,6 +20,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.example.fruitties.database.AppDatabase import com.example.fruitties.database.CartDataStore import com.example.fruitties.database.DB_FILE_NAME +import com.example.fruitties.database.MIGRATION_1_2 import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -36,7 +37,7 @@ actual class Factory { name = dbFile, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .fallbackToDestructiveMigration(dropAllTables = true) + .addMigrations(MIGRATION_1_2) .build() }