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.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.location.Geocoder import org.tasks.location.Geocoder
import org.tasks.location.GeocoderMapbox import org.tasks.location.GeocoderMapbox
import org.tasks.location.GoogleMapFragment import org.tasks.location.GoogleMapFragment
@ -20,7 +21,6 @@ import org.tasks.location.LocationServiceGooglePlay
import org.tasks.location.MapFragment import org.tasks.location.MapFragment
import org.tasks.location.OsmMapFragment import org.tasks.location.OsmMapFragment
import org.tasks.play.PlayServices import org.tasks.play.PlayServices
import org.tasks.wear.LastUpdateSerializer
import org.tasks.wear.WearRefresher import org.tasks.wear.WearRefresher
import org.tasks.wear.WearRefresherImpl import org.tasks.wear.WearRefresherImpl
@ -49,12 +49,7 @@ class FlavorModule {
fun wearDataLayerRegistry( fun wearDataLayerRegistry(
@ApplicationContext applicationContext: Context, @ApplicationContext applicationContext: Context,
@ApplicationScope coroutineScope: CoroutineScope, @ApplicationScope coroutineScope: CoroutineScope,
): WearDataLayerRegistry = WearDataLayerRegistry.fromContext( ) = applicationContext.wearDataLayerRegistry(coroutineScope)
application = applicationContext,
coroutineScope = coroutineScope,
).apply {
registerSerializer(LastUpdateSerializer)
}
@OptIn(ExperimentalHorologistApi::class) @OptIn(ExperimentalHorologistApi::class)
@Provides @Provides

@ -1,13 +1,17 @@
package org.tasks.wear package org.tasks.wear
import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.horologist.annotations.ExperimentalHorologistApi 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.data.WearDataLayerRegistry
import com.google.android.horologist.datalayer.grpc.server.BaseGrpcDataService import com.google.android.horologist.datalayer.grpc.server.BaseGrpcDataService
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskCompleter import com.todoroo.astrid.service.TaskCompleter
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.GrpcProto.Settings
import org.tasks.WearServiceGrpcKt import org.tasks.WearServiceGrpcKt
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter import org.tasks.tasklist.HeaderFormatter
import javax.inject.Inject import javax.inject.Inject
@ -22,12 +26,11 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
@Inject lateinit var headerFormatter: HeaderFormatter @Inject lateinit var headerFormatter: HeaderFormatter
override val registry: WearDataLayerRegistry by lazy { override val registry: WearDataLayerRegistry by lazy {
WearDataLayerRegistry.fromContext( applicationContext.wearDataLayerRegistry(lifecycleScope)
application = applicationContext, }
coroutineScope = lifecycleScope,
).apply { private val settings: DataStore<Settings> by lazy {
registerSerializer(LastUpdateSerializer) registry.protoDataStore(lifecycleScope)
}
} }
override fun buildService(): WearServiceGrpcKt.WearServiceCoroutineImplBase { override fun buildService(): WearServiceGrpcKt.WearServiceCoroutineImplBase {
@ -36,6 +39,7 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
preferences = preferences, preferences = preferences,
taskCompleter = taskCompleter, taskCompleter = taskCompleter,
headerFormatter = headerFormatter, headerFormatter = headerFormatter,
settings = settings,
) )
} }
} }

@ -1,13 +1,18 @@
package org.tasks.wear package org.tasks.wear
import androidx.datastore.core.DataStore
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskCompleter import com.todoroo.astrid.service.TaskCompleter
import kotlinx.coroutines.flow.firstOrNull
import org.tasks.GrpcProto import org.tasks.GrpcProto
import org.tasks.GrpcProto.CompleteTaskRequest import org.tasks.GrpcProto.CompleteTaskRequest
import org.tasks.GrpcProto.CompleteTaskResponse import org.tasks.GrpcProto.CompleteTaskResponse
import org.tasks.GrpcProto.GetTasksRequest import org.tasks.GrpcProto.GetTasksRequest
import org.tasks.GrpcProto.Tasks import org.tasks.GrpcProto.Tasks
import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.ToggleGroupResponse
import org.tasks.WearServiceGrpcKt import org.tasks.WearServiceGrpcKt
import org.tasks.copy
import org.tasks.filters.AstridOrderingFilter import org.tasks.filters.AstridOrderingFilter
import org.tasks.filters.MyTasksFilter import org.tasks.filters.MyTasksFilter
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
@ -20,11 +25,14 @@ class WearService(
private val preferences: Preferences, private val preferences: Preferences,
private val taskCompleter: TaskCompleter, private val taskCompleter: TaskCompleter,
private val headerFormatter: HeaderFormatter, private val headerFormatter: HeaderFormatter,
private val settings: DataStore<GrpcProto.Settings>,
) : WearServiceGrpcKt.WearServiceCoroutineImplBase() { ) : WearServiceGrpcKt.WearServiceCoroutineImplBase() {
override suspend fun getTasks(request: GetTasksRequest): Tasks { override suspend fun getTasks(request: GetTasksRequest): Tasks {
val position = request.position val position = request.position
val limit = request.limit.takeIf { it > 0 } ?: Int.MAX_VALUE val limit = request.limit.takeIf { it > 0 } ?: Int.MAX_VALUE
val filter = MyTasksFilter.create() val filter = MyTasksFilter.create()
val settingsData = settings.data.firstOrNull()
val collapsed = settingsData?.collapsedList?.toSet() ?: emptySet()
val payload = SectionedDataSource( val payload = SectionedDataSource(
tasks = taskDao.fetchTasks(preferences, filter), tasks = taskDao.fetchTasks(preferences, filter),
disableHeaders = filter.disableHeaders() disableHeaders = filter.disableHeaders()
@ -33,6 +41,7 @@ class WearService(
groupMode = preferences.groupMode, groupMode = preferences.groupMode,
subtaskMode = preferences.subtaskMode, subtaskMode = preferences.subtaskMode,
completedAtBottom = preferences.completedTasksAtBottom, completedAtBottom = preferences.completedTasksAtBottom,
collapsed = collapsed,
) )
return Tasks.newBuilder() return Tasks.newBuilder()
.setTotalItems(payload.size) .setTotalItems(payload.size)
@ -46,6 +55,7 @@ class WearService(
.setId(item.value) .setId(item.value)
.setType(GrpcProto.UiItemType.Header) .setType(GrpcProto.UiItemType.Header)
.setTitle(headerFormatter.headerString(item.value)) .setTitle(headerFormatter.headerString(item.value))
.setCollapsed(collapsed.contains(item.value))
.build() .build()
is UiItem.Task -> is UiItem.Task ->
@ -71,4 +81,23 @@ class WearService(
taskCompleter.setComplete(request.id, request.completed) taskCompleter.setComplete(request.id, request.completed)
return CompleteTaskResponse.newBuilder().setSuccess(true).build() 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; bool completed = 4;
uint32 priority = 5; uint32 priority = 5;
bool repeating = 6; bool repeating = 6;
bool collapsed = 7;
} }
message Tasks { message Tasks {
@ -28,6 +29,11 @@ message LastUpdate {
uint64 now = 1; uint64 now = 1;
} }
message Settings {
repeated uint64 collapsed = 1;
string filter = 2;
}
message GetTasksRequest { message GetTasksRequest {
uint32 position = 1; uint32 position = 1;
uint32 limit = 2; uint32 limit = 2;
@ -38,7 +44,14 @@ message CompleteTaskRequest {
} }
message CompleteTaskResponse { bool success = 1; } message CompleteTaskResponse { bool success = 1; }
message ToggleGroupRequest {
uint64 value = 1;
bool collapsed = 2;
}
message ToggleGroupResponse {}
service WearService { service WearService {
rpc getTasks(GetTasksRequest) returns (Tasks); rpc getTasks(GetTasksRequest) returns (Tasks);
rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse); rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse);
rpc toggleGroup(ToggleGroupRequest) returns (ToggleGroupResponse);
} }

@ -60,9 +60,14 @@ class MainActivity : ComponentActivity() {
composable("task_list") { composable("task_list") {
TaskListScreen( TaskListScreen(
uiItems = taskListItems, uiItems = taskListItems,
toggleGroup = { value, collapsed ->
taskListViewModel.toggleGroup(value, collapsed)
},
onComplete = { taskListViewModel.completeTask(it) }, onComplete = { taskListViewModel.completeTask(it) },
onClick = { navController.navigate("task_edit/$it") }, openTask = { navController.navigate("task_edit/$it") },
addTask = {}, addTask = {},
openMenu = { navController.navigate("menu") },
openSettings = { navController.navigate("settings") },
) )
} }
composable( composable(
@ -74,6 +79,16 @@ class MainActivity : ComponentActivity() {
val taskId = it.arguments?.getString("taskId") val taskId = it.arguments?.getString("taskId")
WearApp(taskId ?: "invalid id") WearApp(taskId ?: "invalid id")
} }
composable(
route = "menu",
) {
}
composable(
route = "settings",
) {
}
} }
} }
} }
@ -110,4 +125,4 @@ fun Greeting(greetingName: String) {
@Composable @Composable
fun DefaultPreview() { fun DefaultPreview() {
WearApp("Preview Android") WearApp("Preview Android")
} }

@ -1,18 +1,30 @@
package org.tasks.presentation.screens 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank 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.Repeat
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -40,9 +52,12 @@ import tasks.kmp.generated.resources.add_task
@Composable @Composable
fun TaskListScreen( fun TaskListScreen(
uiItems: LazyPagingItems<GrpcProto.UiItem>, uiItems: LazyPagingItems<GrpcProto.UiItem>,
toggleGroup: (Long, Boolean) -> Unit,
onComplete: (Long) -> Unit, onComplete: (Long) -> Unit,
onClick: (Long) -> Unit, openTask: (Long) -> Unit,
addTask: () -> Unit, addTask: () -> Unit,
openMenu: () -> Unit,
openSettings: () -> Unit,
) { ) {
val columnState = rememberResponsiveColumnState() val columnState = rememberResponsiveColumnState()
ScreenScaffold( ScreenScaffold(
@ -53,20 +68,16 @@ fun TaskListScreen(
columnState = columnState, columnState = columnState,
) { ) {
item { item {
TaskCard( ButtonHeader(
text = stringResource(Res.string.add_task), openMenu = openMenu,
icon = { addTask = addTask,
Icon( openSettings = openSettings,
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,
) )
// TitleHeader(
// title = "My Tasks",
// openMenu = openMenu,
// addTask = addTask,
// )
} }
items( items(
items = uiItems, items = uiItems,
@ -105,11 +116,15 @@ fun TaskListScreen(
) )
} }
}, },
onClick = { onClick(item.id) }, onClick = { openTask(item.id) },
) )
GrpcProto.UiItemType.Header -> GrpcProto.UiItemType.Header ->
GroupSeparator(header = item) GroupSeparator(
title = item.title,
collapsed = item.collapsed,
onClick = { toggleGroup(item.id, !item.collapsed) },
)
else -> { else -> {
throw IllegalStateException("Unknown item type: ${item.type}") throw IllegalStateException("Unknown item type: ${item.type}")
@ -123,14 +138,39 @@ fun TaskListScreen(
@Composable @Composable
fun GroupSeparator( fun GroupSeparator(
header: GrpcProto.UiItem, title: String,
collapsed: Boolean,
onClick: () -> Unit,
) { ) {
Text( Row(
text = header.title, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.padding(vertical = 12.dp) .clip(MaterialTheme.shapes.large)
.fillMaxWidth(), .fillMaxWidth()
textAlign = TextAlign.Center, .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.annotations.ExperimentalHorologistApi
import com.google.android.horologist.data.ProtoDataStoreHelper.protoFlow import com.google.android.horologist.data.ProtoDataStoreHelper.protoFlow
import com.google.android.horologist.data.TargetNodeId import com.google.android.horologist.data.TargetNodeId
import com.google.android.horologist.data.WearDataLayerRegistry
import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -18,10 +17,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.GrpcProto import org.tasks.GrpcProto
import org.tasks.GrpcProto.LastUpdate import org.tasks.GrpcProto.LastUpdate
import org.tasks.GrpcProto.Settings
import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.UiItem import org.tasks.GrpcProto.UiItem
import org.tasks.WearServiceGrpcKt import org.tasks.WearServiceGrpcKt
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.presentation.MyPagingSource import org.tasks.presentation.MyPagingSource
import org.tasks.wear.LastUpdateSerializer
@OptIn(ExperimentalHorologistApi::class) @OptIn(ExperimentalHorologistApi::class)
class TaskListViewModel( class TaskListViewModel(
@ -49,13 +50,9 @@ class TaskListViewModel(
.flow .flow
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
private val wearDataLayerRegistry = WearDataLayerRegistry.fromContext( private val registry = application.wearDataLayerRegistry(viewModelScope)
application = application,
coroutineScope = viewModelScope, private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = registry.grpcClient(
).apply {
registerSerializer(LastUpdateSerializer)
}
private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = wearDataLayerRegistry.grpcClient(
nodeId = TargetNodeId.PairedPhone, nodeId = TargetNodeId.PairedPhone,
coroutineScope = viewModelScope, coroutineScope = viewModelScope,
) { ) {
@ -63,10 +60,23 @@ class TaskListViewModel(
} }
init { init {
wearDataLayerRegistry registry
.protoFlow<LastUpdate>(TargetNodeId.PairedPhone) .protoFlow<LastUpdate>(TargetNodeId.PairedPhone)
.onEach { pagingSource?.invalidate() } .onEach { pagingSource?.invalidate() }
.launchIn(viewModelScope) .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 { fun completeTask(it: Long) = viewModelScope.launch {

Loading…
Cancel
Save