From dd6ba730e965fa69047ded556387ebbe7411bd01 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 17 Oct 2024 01:52:49 -0500 Subject: [PATCH] Use paged data on Android Wear --- .../java/org/tasks/wear/WearService.kt | 45 +++++++++------- gradle/libs.versions.toml | 1 + .../org/tasks/tasklist/SectionedDataSource.kt | 2 +- wear-datalayer/src/main/proto/grpc.proto | 5 +- wear/build.gradle.kts | 1 + .../org/tasks/presentation/MainActivity.kt | 5 +- .../org/tasks/presentation/MyPagingSource.kt | 36 +++++++++++++ .../presentation/screens/TaskListScreen.kt | 12 +++-- .../presentation/screens/TaskListViewModel.kt | 52 +++++++++++-------- 9 files changed, 109 insertions(+), 50 deletions(-) create mode 100644 wear/src/main/java/org/tasks/presentation/MyPagingSource.kt diff --git a/app/src/googleplay/java/org/tasks/wear/WearService.kt b/app/src/googleplay/java/org/tasks/wear/WearService.kt index 83dd49ffb..6ea2f2bff 100644 --- a/app/src/googleplay/java/org/tasks/wear/WearService.kt +++ b/app/src/googleplay/java/org/tasks/wear/WearService.kt @@ -22,6 +22,8 @@ class WearService( private val headerFormatter: HeaderFormatter, ) : WearServiceGrpcKt.WearServiceCoroutineImplBase() { override suspend fun getTasks(request: GetTasksRequest): Tasks { + val position = request.position + val limit = request.limit.takeIf { it > 0 } ?: Int.MAX_VALUE val filter = MyTasksFilter.create() val payload = SectionedDataSource( tasks = taskDao.fetchTasks(preferences, filter), @@ -34,27 +36,30 @@ class WearService( ) return Tasks.newBuilder() .addAllItems( - payload.map { item -> - when (item) { - is UiItem.Header -> - GrpcProto.UiItem.newBuilder() - .setType(GrpcProto.UiItemType.Header) - .setTitle(headerFormatter.headerString(item.value)) - .build() - is UiItem.Task -> - GrpcProto.UiItem.newBuilder() - .setType(GrpcProto.UiItemType.Task) - .setId(item.task.id) - .setPriority(item.task.priority) - .setCompleted(item.task.isCompleted) - .apply { - if (item.task.title != null) { - setTitle(item.task.title) + payload + .subList(position, position + limit) + .map { item -> + when (item) { + is UiItem.Header -> + GrpcProto.UiItem.newBuilder() + .setType(GrpcProto.UiItemType.Header) + .setTitle(headerFormatter.headerString(item.value)) + .build() + + is UiItem.Task -> + GrpcProto.UiItem.newBuilder() + .setType(GrpcProto.UiItemType.Task) + .setId(item.task.id) + .setPriority(item.task.priority) + .setCompleted(item.task.isCompleted) + .apply { + if (item.task.title != null) { + setTitle(item.task.title) + } } - } - .setRepeating(item.task.task.isRecurring) - .build() - } + .setRepeating(item.task.task.isRecurring) + .build() + } } ) .build() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cfbdfb4b4..77faa08de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,6 +87,7 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "3.3.2" } androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } androidx-room = { module = "androidx.room:room-runtime", version.ref = "room" } diff --git a/kmp/src/commonMain/kotlin/org/tasks/tasklist/SectionedDataSource.kt b/kmp/src/commonMain/kotlin/org/tasks/tasklist/SectionedDataSource.kt index 33aeafbbc..39af882cc 100644 --- a/kmp/src/commonMain/kotlin/org/tasks/tasklist/SectionedDataSource.kt +++ b/kmp/src/commonMain/kotlin/org/tasks/tasklist/SectionedDataSource.kt @@ -73,7 +73,7 @@ class SectionedDataSource( } override fun subList(fromIndex: Int, toIndex: Int): List { - TODO("Not yet implemented") + return iterator().asSequence().drop(fromIndex).take(toIndex - fromIndex).toList() } override fun lastIndexOf(element: UiItem): Int { diff --git a/wear-datalayer/src/main/proto/grpc.proto b/wear-datalayer/src/main/proto/grpc.proto index 5cafc7e30..8b10bcd27 100644 --- a/wear-datalayer/src/main/proto/grpc.proto +++ b/wear-datalayer/src/main/proto/grpc.proto @@ -27,7 +27,10 @@ message LastUpdate { uint64 now = 1; } -message GetTasksRequest {} +message GetTasksRequest { + uint32 position = 1; + uint32 limit = 2; +} message CompleteTaskRequest { uint64 id = 1; bool completed = 2; diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 8b38effd4..253a64858 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(projects.kmp) implementation(libs.play.services.wearable) implementation(platform(libs.androidx.compose)) + implementation(libs.androidx.paging.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) diff --git a/wear/src/main/java/org/tasks/presentation/MainActivity.kt b/wear/src/main/java/org/tasks/presentation/MainActivity.kt index ba4898a22..df29fdab9 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivity.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt @@ -19,10 +19,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavType import androidx.navigation.navArgument +import androidx.paging.compose.collectAsLazyPagingItems import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText @@ -57,9 +57,8 @@ class MainActivity : ComponentActivity() { ) { composable("task_list") { navBackStackEntry -> val viewModel: TaskListViewModel = viewModel(navBackStackEntry) - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value TaskListScreen( - uiItems = uiState.tasks.itemsList, + uiItems = viewModel.uiItems.collectAsLazyPagingItems(), onComplete = { viewModel.completeTask(it) }, onClick = { navController.navigate("task_edit/$it") }, ) diff --git a/wear/src/main/java/org/tasks/presentation/MyPagingSource.kt b/wear/src/main/java/org/tasks/presentation/MyPagingSource.kt new file mode 100644 index 000000000..1773a049c --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/MyPagingSource.kt @@ -0,0 +1,36 @@ +package org.tasks.presentation + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MyPagingSource( + private val fetch: suspend (position: Int, limit: Int) -> List?, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key ?: 0 + val limit = params.loadSize + + return try { + val items = withContext (Dispatchers.IO) { + fetch(position, limit) ?: emptyList() + } + + LoadResult.Page( + data = items, + prevKey = if (position <= 0) null else position - limit, + nextKey = if (items.isEmpty()) null else position + limit + ) + } catch (e: Exception) { + Log.e("MyPagingSource", "${e.message}\n${e.stackTrace}") + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int { + return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0) + } +} diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt index 74fc0c1a8..c26e8bef2 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.Card @@ -33,18 +34,21 @@ import org.tasks.kmp.org.tasks.themes.ColorProvider @OptIn(ExperimentalHorologistApi::class) @Composable fun TaskListScreen( - uiItems: List, + uiItems: LazyPagingItems, onComplete: (Long) -> Unit, onClick: (Long) -> Unit, ) { val columnState = rememberResponsiveColumnState() - ScreenScaffold(scrollState = columnState) { + ScreenScaffold( + scrollState = columnState, + positionIndicator = {}, + ) { ScalingLazyColumn( modifier = Modifier.fillMaxSize(), columnState = columnState, ) { - items(uiItems.size) { index -> - val item = uiItems[index] + items(uiItems.itemCount) { index -> + val item = uiItems[index] ?: return@items key(item.id) { when (item.type) { GrpcProto.UiItemType.Task -> diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt index 3f944fa3d..e54f07e6b 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt @@ -3,60 +3,70 @@ package org.tasks.presentation.screens import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.data.ProtoDataStoreHelper.protoFlow import com.google.android.horologist.data.TargetNodeId import com.google.android.horologist.data.WearDataLayerRegistry import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.tasks.GrpcProto import org.tasks.GrpcProto.LastUpdate -import org.tasks.GrpcProto.Tasks +import org.tasks.GrpcProto.UiItem import org.tasks.WearServiceGrpcKt +import org.tasks.presentation.MyPagingSource import org.tasks.wear.LastUpdateSerializer -data class TaskListScreenState( - val error: String? = null, - val tasks: Tasks = Tasks.getDefaultInstance(), -) - @OptIn(ExperimentalHorologistApi::class) class TaskListViewModel( application: Application ) : AndroidViewModel(application) { - val uiState: MutableStateFlow = MutableStateFlow(TaskListScreenState()) - private val scope = CoroutineScope(Dispatchers.IO) + private var pagingSource: MyPagingSource? = null + val uiItems: Flow> = Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { + MyPagingSource { position, limit -> + wearService + .getTasks( + GrpcProto + .GetTasksRequest + .newBuilder() + .setPosition(position) + .setLimit(limit) + .build() + ) + .itemsList + } + .also { pagingSource = it } + } + ) + .flow private val wearDataLayerRegistry = WearDataLayerRegistry.fromContext( application = application, - coroutineScope = scope, + coroutineScope = viewModelScope, ).apply { registerSerializer(LastUpdateSerializer) } private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = wearDataLayerRegistry.grpcClient( nodeId = TargetNodeId.PairedPhone, - coroutineScope = scope, + coroutineScope = viewModelScope, ) { WearServiceGrpcKt.WearServiceCoroutineStub(it) } - private val lastUpdate: Flow = wearDataLayerRegistry.protoFlow(TargetNodeId.PairedPhone) init { - lastUpdate - .onEach { - val tasks = wearService.getTasks(GrpcProto.GetTasksRequest.getDefaultInstance()) - uiState.update { it.copy(tasks = tasks) } - } + wearDataLayerRegistry + .protoFlow(TargetNodeId.PairedPhone) + .onEach { pagingSource?.invalidate() } .launchIn(viewModelScope) } fun completeTask(it: Long) = viewModelScope.launch { wearService.completeTask(GrpcProto.CompleteTaskRequest.newBuilder().setId(it).setCompleted(true).build()) } -} \ No newline at end of file +}