Collapsible sort groups on Android Wear

pull/3074/head
Alex Baker 1 year ago
parent f083083850
commit 502f7c07d5

@ -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

@ -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<WearServiceGrpcKt.WearServiceCorouti
@Inject lateinit var headerFormatter: HeaderFormatter
override val registry: WearDataLayerRegistry by lazy {
WearDataLayerRegistry.fromContext(
application = applicationContext,
coroutineScope = lifecycleScope,
).apply {
registerSerializer(LastUpdateSerializer)
applicationContext.wearDataLayerRegistry(lifecycleScope)
}
private val settings: DataStore<Settings> by lazy {
registry.protoDataStore(lifecycleScope)
}
override fun buildService(): WearServiceGrpcKt.WearServiceCoroutineImplBase {
@ -36,6 +39,7 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
preferences = preferences,
taskCompleter = taskCompleter,
headerFormatter = headerFormatter,
settings = settings,
)
}
}

@ -1,13 +1,18 @@
package org.tasks.wear
import androidx.datastore.core.DataStore
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskCompleter
import kotlinx.coroutines.flow.firstOrNull
import org.tasks.GrpcProto
import org.tasks.GrpcProto.CompleteTaskRequest
import org.tasks.GrpcProto.CompleteTaskResponse
import org.tasks.GrpcProto.GetTasksRequest
import org.tasks.GrpcProto.Tasks
import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.ToggleGroupResponse
import org.tasks.WearServiceGrpcKt
import org.tasks.copy
import org.tasks.filters.AstridOrderingFilter
import org.tasks.filters.MyTasksFilter
import org.tasks.preferences.Preferences
@ -20,11 +25,14 @@ class WearService(
private val preferences: Preferences,
private val taskCompleter: TaskCompleter,
private val headerFormatter: HeaderFormatter,
private val settings: DataStore<GrpcProto.Settings>,
) : 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()
}
}

@ -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)
}

@ -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<Settings> {
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)
}
}

@ -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);
}

@ -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",
) {
}
}
}
}

@ -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<GrpcProto.UiItem>,
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,
)
}
}
}

@ -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<LastUpdate>(TargetNodeId.PairedPhone)
.onEach { pagingSource?.invalidate() }
.launchIn(viewModelScope)
registry
.protoFlow<Settings>(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 {

Loading…
Cancel
Save