From 45fbb2794dc0485996dc8cea4558a70cf40b85e0 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 19 Oct 2024 10:47:25 -0500 Subject: [PATCH] Subtask support on Android Wear --- .../java/org/tasks/wear/WearService.kt | 8 + wear-datalayer/src/main/proto/grpc.proto | 3 + .../org/tasks/presentation/MainActivity.kt | 3 + .../tasks/presentation/components/Chevron.kt | 27 ++++ .../presentation/components/GroupSeparator.kt | 39 +++++ .../tasks/presentation/components/TaskCard.kt | 69 +++++++++ .../presentation/screens/TaskListScreen.kt | 145 +++++------------- .../presentation/screens/TaskListViewModel.kt | 11 +- 8 files changed, 196 insertions(+), 109 deletions(-) create mode 100644 wear/src/main/java/org/tasks/presentation/components/Chevron.kt create mode 100644 wear/src/main/java/org/tasks/presentation/components/GroupSeparator.kt create mode 100644 wear/src/main/java/org/tasks/presentation/components/TaskCard.kt diff --git a/app/src/googleplay/java/org/tasks/wear/WearService.kt b/app/src/googleplay/java/org/tasks/wear/WearService.kt index e2cf44bcb..263529514 100644 --- a/app/src/googleplay/java/org/tasks/wear/WearService.kt +++ b/app/src/googleplay/java/org/tasks/wear/WearService.kt @@ -67,6 +67,9 @@ class WearService( .setPriority(item.task.priority) .setCompleted(item.task.isCompleted) .setHidden(item.task.task.isHidden) + .setIndent(item.task.indent) + .setCollapsed(item.task.isCollapsed) + .setNumSubtasks(item.task.children) .apply { if (item.task.title != null) { setTitle(item.task.title) @@ -107,4 +110,9 @@ class WearService( override suspend fun updateSettings(request: GrpcProto.UpdateSettingsRequest): GrpcProto.Settings { return settings.updateData { request.settings } } + + override suspend fun toggleSubtasks(request: ToggleGroupRequest): ToggleGroupResponse { + taskDao.setCollapsed(request.value, request.collapsed) + return ToggleGroupResponse.newBuilder().build() + } } diff --git a/wear-datalayer/src/main/proto/grpc.proto b/wear-datalayer/src/main/proto/grpc.proto index fd174f4f2..77e067dff 100644 --- a/wear-datalayer/src/main/proto/grpc.proto +++ b/wear-datalayer/src/main/proto/grpc.proto @@ -19,6 +19,8 @@ message UiItem { bool repeating = 6; bool collapsed = 7; bool hidden = 8; + uint32 indent = 9; + uint32 numSubtasks = 10; } message Tasks { @@ -61,4 +63,5 @@ service WearService { rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse); rpc toggleGroup(ToggleGroupRequest) returns (ToggleGroupResponse); rpc updateSettings(UpdateSettingsRequest) returns (Settings); + rpc toggleSubtasks(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 bb3cfc788..78568dedc 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivity.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt @@ -73,6 +73,9 @@ class MainActivity : ComponentActivity() { addTask = {}, openMenu = { navController.navigate("menu") }, openSettings = { navController.navigate("settings") }, + toggleSubtasks = { id, collapsed -> + taskListViewModel.toggleSubtasks(id, collapsed) + } ) } composable( diff --git a/wear/src/main/java/org/tasks/presentation/components/Chevron.kt b/wear/src/main/java/org/tasks/presentation/components/Chevron.kt new file mode 100644 index 000000000..9bff4d74b --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/components/Chevron.kt @@ -0,0 +1,27 @@ +package org.tasks.presentation.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme + +@Composable +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, + ) +} diff --git a/wear/src/main/java/org/tasks/presentation/components/GroupSeparator.kt b/wear/src/main/java/org/tasks/presentation/components/GroupSeparator.kt new file mode 100644 index 000000000..c0cd7bc25 --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/components/GroupSeparator.kt @@ -0,0 +1,39 @@ +package org.tasks.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text + +@Composable +fun GroupSeparator( + title: String, + collapsed: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(12.dp) + ) { + Text( + text = title, + ) + Spacer(modifier = Modifier.width(4.dp)) + Chevron(collapsed = collapsed) + } +} diff --git a/wear/src/main/java/org/tasks/presentation/components/TaskCard.kt b/wear/src/main/java/org/tasks/presentation/components/TaskCard.kt new file mode 100644 index 000000000..f0dcf38ab --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/components/TaskCard.kt @@ -0,0 +1,69 @@ +package org.tasks.presentation.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Card +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text + +@Composable +fun TaskCard( + text: String, + hidden: Boolean = false, + numSubtasks: Int = 0, + subtasksCollapsed: Boolean = false, + toggleSubtasks: () -> Unit = {}, + icon: @Composable () -> Unit = {}, + backgroundColor: Color = MaterialTheme.colors.surface, + contentColor: Color = MaterialTheme.colors.onSurface, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + backgroundPainter = ColorPainter(backgroundColor), + contentPadding = PaddingValues(0.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Text( + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = contentColor, + modifier = Modifier.alpha(if (hidden) .6f else 1f).weight(1f), + ) + if (numSubtasks > 0) { + Button( + onClick = toggleSubtasks, + colors = ButtonDefaults.outlinedButtonColors() + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = numSubtasks.toString(), // TODO: use number formatter + color = contentColor, + ) + Chevron(subtasksCollapsed) + } + } + } else { + Spacer(modifier = Modifier.width(12.dp)) + } + } + } +} 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 904dbfb6a..63d515f1c 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt @@ -1,40 +1,27 @@ 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.alpha -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 -import androidx.compose.ui.text.style.TextOverflow 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 import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text @@ -46,6 +33,8 @@ import com.google.android.horologist.compose.paging.items import org.jetbrains.compose.resources.stringResource import org.tasks.GrpcProto import org.tasks.kmp.org.tasks.themes.ColorProvider +import org.tasks.presentation.components.GroupSeparator +import org.tasks.presentation.components.TaskCard import tasks.kmp.generated.resources.Res import tasks.kmp.generated.resources.add_task @@ -59,6 +48,7 @@ fun TaskListScreen( addTask: () -> Unit, openMenu: () -> Unit, openSettings: () -> Unit, + toggleSubtasks: (Long, Boolean) -> Unit, ) { val columnState = rememberResponsiveColumnState() ScreenScaffold( @@ -93,33 +83,41 @@ fun TaskListScreen( } else { when (item.type) { GrpcProto.UiItemType.Task -> - TaskCard( - text = item.title, - hidden = item.hidden, - icon = { - Button( - onClick = { onComplete(item.id, !item.completed) }, - colors = ButtonDefaults.iconButtonColors(), - ) { - Icon( - imageVector = when { - item.completed -> Icons.Outlined.CheckBox - item.repeating -> Icons.Outlined.Repeat - else -> Icons.Outlined.CheckBoxOutlineBlank - }, - tint = Color( - ColorProvider.priorityColor( - item.priority, - isDarkMode = true, - desaturate = true - ) - ), - contentDescription = null, - ) - } - }, - onClick = { openTask(item.id) }, - ) + Row { + if (item.indent > 0) { + Spacer(modifier = Modifier.width(20.dp * item.indent)) + } + TaskCard( + text = item.title, + hidden = item.hidden, + subtasksCollapsed = item.collapsed, + numSubtasks = item.numSubtasks, + icon = { + Button( + onClick = { onComplete(item.id, !item.completed) }, + colors = ButtonDefaults.iconButtonColors(), + ) { + Icon( + imageVector = when { + item.completed -> Icons.Outlined.CheckBox + item.repeating -> Icons.Outlined.Repeat + else -> Icons.Outlined.CheckBoxOutlineBlank + }, + tint = Color( + ColorProvider.priorityColor( + item.priority, + isDarkMode = true, + desaturate = true + ) + ), + contentDescription = null, + ) + } + }, + onClick = { openTask(item.id) }, + toggleSubtasks = { toggleSubtasks(item.id, !item.collapsed) }, + ) + } GrpcProto.UiItemType.Header -> GroupSeparator( @@ -138,73 +136,6 @@ fun TaskListScreen( } } -@Composable -fun GroupSeparator( - title: String, - collapsed: Boolean, - onClick: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .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, - ) -} - -@Composable -fun TaskCard( - text: String, - hidden: Boolean = false, - icon: @Composable () -> Unit = {}, - backgroundColor: Color = MaterialTheme.colors.surface, - contentColor: Color = MaterialTheme.colors.onSurface, - onClick: () -> Unit, -) { - Card( - onClick = onClick, - backgroundPainter = ColorPainter(backgroundColor), - contentPadding = PaddingValues(start = 0.dp, top = 0.dp, end = 12.dp, bottom = 0.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - icon() - Text( - text = text, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = contentColor, - modifier = Modifier.alpha(if (hidden) .6f else 1f) - ) - } - } -} - @Composable fun TitleHeader( title: String, 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 9edba1b68..ebf6e20e8 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.tasks.GrpcProto +import org.tasks.GrpcProto.CompleteTaskRequest import org.tasks.GrpcProto.LastUpdate import org.tasks.GrpcProto.Settings import org.tasks.GrpcProto.ToggleGroupRequest @@ -79,9 +80,15 @@ class TaskListViewModel( ) } - fun completeTask(it: Long, completed: Boolean) = viewModelScope.launch { + fun completeTask(id: Long, completed: Boolean) = viewModelScope.launch { wearService.completeTask( - GrpcProto.CompleteTaskRequest.newBuilder().setId(it).setCompleted(completed).build() + CompleteTaskRequest.newBuilder().setId(id).setCompleted(completed).build() + ) + } + + fun toggleSubtasks(id: Long, collapsed: Boolean) = viewModelScope.launch { + wearService.toggleSubtasks( + ToggleGroupRequest.newBuilder().setValue(id).setCollapsed(collapsed).build() ) } }