From 502f7c07d5dbf3e9898d5c40ed1bcba62ce247b1 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 19 Oct 2024 01:25:00 -0500 Subject: [PATCH] Collapsible sort groups on Android Wear --- .../java/org/tasks/injection/FlavorModule.kt | 9 +- .../java/org/tasks/wear/WearDataService.kt | 16 +- .../java/org/tasks/wear/WearService.kt | 29 +++ .../org/tasks/extensions/ContextExtensions.kt | 18 ++ .../java/org/tasks/wear/SettingsSerializer.kt | 24 +++ wear-datalayer/src/main/proto/grpc.proto | 13 ++ .../org/tasks/presentation/MainActivity.kt | 19 +- .../presentation/screens/TaskListScreen.kt | 174 +++++++++++++++--- .../presentation/screens/TaskListViewModel.kt | 30 ++- 9 files changed, 285 insertions(+), 47 deletions(-) create mode 100644 wear-datalayer/src/main/java/org/tasks/extensions/ContextExtensions.kt create mode 100644 wear-datalayer/src/main/java/org/tasks/wear/SettingsSerializer.kt diff --git a/app/src/googleplay/java/org/tasks/injection/FlavorModule.kt b/app/src/googleplay/java/org/tasks/injection/FlavorModule.kt index c009a4646..33f0209a9 100644 --- a/app/src/googleplay/java/org/tasks/injection/FlavorModule.kt +++ b/app/src/googleplay/java/org/tasks/injection/FlavorModule.kt @@ -11,6 +11,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope +import org.tasks.extensions.wearDataLayerRegistry import org.tasks.location.Geocoder import org.tasks.location.GeocoderMapbox import org.tasks.location.GoogleMapFragment @@ -20,7 +21,6 @@ import org.tasks.location.LocationServiceGooglePlay import org.tasks.location.MapFragment import org.tasks.location.OsmMapFragment import org.tasks.play.PlayServices -import org.tasks.wear.LastUpdateSerializer import org.tasks.wear.WearRefresher import org.tasks.wear.WearRefresherImpl @@ -49,12 +49,7 @@ class FlavorModule { fun wearDataLayerRegistry( @ApplicationContext applicationContext: Context, @ApplicationScope coroutineScope: CoroutineScope, - ): WearDataLayerRegistry = WearDataLayerRegistry.fromContext( - application = applicationContext, - coroutineScope = coroutineScope, - ).apply { - registerSerializer(LastUpdateSerializer) - } + ) = applicationContext.wearDataLayerRegistry(coroutineScope) @OptIn(ExperimentalHorologistApi::class) @Provides diff --git a/app/src/googleplay/java/org/tasks/wear/WearDataService.kt b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt index 69dbebc4c..f2a73316a 100644 --- a/app/src/googleplay/java/org/tasks/wear/WearDataService.kt +++ b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt @@ -1,13 +1,17 @@ package org.tasks.wear +import androidx.datastore.core.DataStore import androidx.lifecycle.lifecycleScope import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.data.ProtoDataStoreHelper.protoDataStore import com.google.android.horologist.data.WearDataLayerRegistry import com.google.android.horologist.datalayer.grpc.server.BaseGrpcDataService import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.service.TaskCompleter import dagger.hilt.android.AndroidEntryPoint +import org.tasks.GrpcProto.Settings import org.tasks.WearServiceGrpcKt +import org.tasks.extensions.wearDataLayerRegistry import org.tasks.preferences.Preferences import org.tasks.tasklist.HeaderFormatter import javax.inject.Inject @@ -22,12 +26,11 @@ class WearDataService : BaseGrpcDataService by lazy { + registry.protoDataStore(lifecycleScope) } override fun buildService(): WearServiceGrpcKt.WearServiceCoroutineImplBase { @@ -36,6 +39,7 @@ class WearDataService : BaseGrpcDataService, ) : 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 settingsData = settings.data.firstOrNull() + val collapsed = settingsData?.collapsedList?.toSet() ?: emptySet() val payload = SectionedDataSource( tasks = taskDao.fetchTasks(preferences, filter), disableHeaders = filter.disableHeaders() @@ -33,6 +41,7 @@ class WearService( groupMode = preferences.groupMode, subtaskMode = preferences.subtaskMode, completedAtBottom = preferences.completedTasksAtBottom, + collapsed = collapsed, ) return Tasks.newBuilder() .setTotalItems(payload.size) @@ -46,6 +55,7 @@ class WearService( .setId(item.value) .setType(GrpcProto.UiItemType.Header) .setTitle(headerFormatter.headerString(item.value)) + .setCollapsed(collapsed.contains(item.value)) .build() is UiItem.Task -> @@ -71,4 +81,23 @@ class WearService( taskCompleter.setComplete(request.id, request.completed) return CompleteTaskResponse.newBuilder().setSuccess(true).build() } + + override suspend fun toggleGroup(request: ToggleGroupRequest): ToggleGroupResponse { + settings.updateData { + it.copy { + if (request.collapsed) { + if (!collapsed.contains(request.value)) { + collapsed.add(request.value) + } + } else { + if (collapsed.contains(request.value)) { + collapsed.clear() + collapsed.addAll(it.collapsedList.toMutableList().apply { remove(request.value) }) + } + } + } + } + + return ToggleGroupResponse.getDefaultInstance() + } } diff --git a/wear-datalayer/src/main/java/org/tasks/extensions/ContextExtensions.kt b/wear-datalayer/src/main/java/org/tasks/extensions/ContextExtensions.kt new file mode 100644 index 000000000..c62b707a5 --- /dev/null +++ b/wear-datalayer/src/main/java/org/tasks/extensions/ContextExtensions.kt @@ -0,0 +1,18 @@ +package org.tasks.extensions + +import android.content.Context +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.data.WearDataLayerRegistry +import kotlinx.coroutines.CoroutineScope +import org.tasks.wear.LastUpdateSerializer +import org.tasks.wear.SettingsSerializer + +@OptIn(ExperimentalHorologistApi::class) +fun Context.wearDataLayerRegistry(scope: CoroutineScope) = + WearDataLayerRegistry.fromContext( + application = applicationContext, + coroutineScope = scope, + ).apply { + registerSerializer(SettingsSerializer) + registerSerializer(LastUpdateSerializer) + } diff --git a/wear-datalayer/src/main/java/org/tasks/wear/SettingsSerializer.kt b/wear-datalayer/src/main/java/org/tasks/wear/SettingsSerializer.kt new file mode 100644 index 000000000..c33604a91 --- /dev/null +++ b/wear-datalayer/src/main/java/org/tasks/wear/SettingsSerializer.kt @@ -0,0 +1,24 @@ +package org.tasks.wear + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import org.tasks.GrpcProto.Settings +import java.io.InputStream +import java.io.OutputStream + +object SettingsSerializer : Serializer { + override val defaultValue: Settings + get() = Settings.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): Settings = + try { + Settings.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: Settings, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/wear-datalayer/src/main/proto/grpc.proto b/wear-datalayer/src/main/proto/grpc.proto index 6b0818c52..e6a666673 100644 --- a/wear-datalayer/src/main/proto/grpc.proto +++ b/wear-datalayer/src/main/proto/grpc.proto @@ -17,6 +17,7 @@ message UiItem { bool completed = 4; uint32 priority = 5; bool repeating = 6; + bool collapsed = 7; } message Tasks { @@ -28,6 +29,11 @@ message LastUpdate { uint64 now = 1; } +message Settings { + repeated uint64 collapsed = 1; + string filter = 2; +} + message GetTasksRequest { uint32 position = 1; uint32 limit = 2; @@ -38,7 +44,14 @@ message CompleteTaskRequest { } message CompleteTaskResponse { bool success = 1; } +message ToggleGroupRequest { + uint64 value = 1; + bool collapsed = 2; +} +message ToggleGroupResponse {} + service WearService { rpc getTasks(GetTasksRequest) returns (Tasks); rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse); + rpc toggleGroup(ToggleGroupRequest) returns (ToggleGroupResponse); } diff --git a/wear/src/main/java/org/tasks/presentation/MainActivity.kt b/wear/src/main/java/org/tasks/presentation/MainActivity.kt index 42e57f634..4d589cffc 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivity.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt @@ -60,9 +60,14 @@ class MainActivity : ComponentActivity() { composable("task_list") { TaskListScreen( uiItems = taskListItems, + toggleGroup = { value, collapsed -> + taskListViewModel.toggleGroup(value, collapsed) + }, onComplete = { taskListViewModel.completeTask(it) }, - onClick = { navController.navigate("task_edit/$it") }, + openTask = { navController.navigate("task_edit/$it") }, addTask = {}, + openMenu = { navController.navigate("menu") }, + openSettings = { navController.navigate("settings") }, ) } composable( @@ -74,6 +79,16 @@ class MainActivity : ComponentActivity() { val taskId = it.arguments?.getString("taskId") WearApp(taskId ?: "invalid id") } + composable( + route = "menu", + ) { + + } + composable( + route = "settings", + ) { + + } } } } @@ -110,4 +125,4 @@ fun Greeting(greetingName: String) { @Composable fun DefaultPreview() { WearApp("Preview Android") -} \ No newline at end of file +} 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 e8c903dc7..a4e93b348 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt @@ -1,18 +1,30 @@ package org.tasks.presentation.screens +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Repeat +import androidx.compose.material.icons.outlined.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.text.style.TextAlign @@ -40,9 +52,12 @@ import tasks.kmp.generated.resources.add_task @Composable fun TaskListScreen( uiItems: LazyPagingItems, + toggleGroup: (Long, Boolean) -> Unit, onComplete: (Long) -> Unit, - onClick: (Long) -> Unit, + openTask: (Long) -> Unit, addTask: () -> Unit, + openMenu: () -> Unit, + openSettings: () -> Unit, ) { val columnState = rememberResponsiveColumnState() ScreenScaffold( @@ -53,20 +68,16 @@ fun TaskListScreen( columnState = columnState, ) { item { - TaskCard( - text = stringResource(Res.string.add_task), - icon = { - Icon( - imageVector = Icons.Outlined.Add, - tint = MaterialTheme.colors.onPrimary, - contentDescription = null, - modifier = Modifier.padding(12.dp), - ) - }, - backgroundColor = MaterialTheme.colors.primary, - contentColor = MaterialTheme.colors.onPrimary, - onClick = addTask, + ButtonHeader( + openMenu = openMenu, + addTask = addTask, + openSettings = openSettings, ) +// TitleHeader( +// title = "My Tasks", +// openMenu = openMenu, +// addTask = addTask, +// ) } items( items = uiItems, @@ -105,11 +116,15 @@ fun TaskListScreen( ) } }, - onClick = { onClick(item.id) }, + onClick = { openTask(item.id) }, ) GrpcProto.UiItemType.Header -> - GroupSeparator(header = item) + GroupSeparator( + title = item.title, + collapsed = item.collapsed, + onClick = { toggleGroup(item.id, !item.collapsed) }, + ) else -> { throw IllegalStateException("Unknown item type: ${item.type}") @@ -123,14 +138,39 @@ fun TaskListScreen( @Composable fun GroupSeparator( - header: GrpcProto.UiItem, + title: String, + collapsed: Boolean, + onClick: () -> Unit, ) { - Text( - text = header.title, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, modifier = Modifier - .padding(vertical = 12.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center, + .clip(MaterialTheme.shapes.large) + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(12.dp) + ) { + Text( + text = title, + ) + Spacer(modifier = Modifier.width(4.dp)) + Chevron(collapsed = collapsed) + } +} + +@Composable +private fun Chevron(collapsed: Boolean) { + val rotation by animateFloatAsState( + targetValue = if (collapsed) 0f else 180f, + animationSpec = tween(250), + label = "arrow rotation", + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colors.onSurface, ) } @@ -160,3 +200,93 @@ fun TaskCard( } } } + +@Composable +fun TitleHeader( + title: String, + openMenu: () -> Unit, + addTask: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = openMenu, + colors = ButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ) + ) { + Icon( + imageVector = Icons.Outlined.Menu, + contentDescription = null, + ) + } + Text( + text = title, + maxLines = 2, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.title3, + textAlign = TextAlign.Center, + ) + Button( + onClick = addTask, + colors = ButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ) + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = org.jetbrains.compose.resources.stringResource(Res.string.add_task), + ) + } + } +} + +@Composable +fun ButtonHeader( + openMenu: () -> Unit, + addTask: () -> Unit, + openSettings: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = openMenu, + colors = ButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ) + ) { + Icon( + imageVector = Icons.Outlined.Menu, + contentDescription = null, + ) + } + Button( + onClick = addTask, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary, + contentColor = MaterialTheme.colors.onPrimary, + ) + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(Res.string.add_task), + ) + } + Button( + onClick = openSettings, + colors = ButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ) + + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + ) + } + } +} 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 52bc50859..ad0d77315 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt @@ -10,7 +10,6 @@ import androidx.paging.cachedIn 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.flow.Flow import kotlinx.coroutines.flow.launchIn @@ -18,10 +17,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.tasks.GrpcProto import org.tasks.GrpcProto.LastUpdate +import org.tasks.GrpcProto.Settings +import org.tasks.GrpcProto.ToggleGroupRequest import org.tasks.GrpcProto.UiItem import org.tasks.WearServiceGrpcKt +import org.tasks.extensions.wearDataLayerRegistry import org.tasks.presentation.MyPagingSource -import org.tasks.wear.LastUpdateSerializer @OptIn(ExperimentalHorologistApi::class) class TaskListViewModel( @@ -49,13 +50,9 @@ class TaskListViewModel( .flow .cachedIn(viewModelScope) - private val wearDataLayerRegistry = WearDataLayerRegistry.fromContext( - application = application, - coroutineScope = viewModelScope, - ).apply { - registerSerializer(LastUpdateSerializer) - } - private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = wearDataLayerRegistry.grpcClient( + private val registry = application.wearDataLayerRegistry(viewModelScope) + + private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = registry.grpcClient( nodeId = TargetNodeId.PairedPhone, coroutineScope = viewModelScope, ) { @@ -63,10 +60,23 @@ class TaskListViewModel( } init { - wearDataLayerRegistry + registry .protoFlow(TargetNodeId.PairedPhone) .onEach { pagingSource?.invalidate() } .launchIn(viewModelScope) + registry + .protoFlow(TargetNodeId.PairedPhone) + .onEach { pagingSource?.invalidate() } + .launchIn(viewModelScope) + } + + fun toggleGroup(value: Long, setCollapsed: Boolean) = viewModelScope.launch { + wearService.toggleGroup( + ToggleGroupRequest.newBuilder() + .setValue(value) + .setCollapsed(setCollapsed) + .build() + ) } fun completeTask(it: Long) = viewModelScope.launch {