Implement drawer for Android Wear

pull/3074/head
Alex Baker 1 year ago
parent 689cd20a88
commit 105757af53

@ -11,9 +11,14 @@ import com.todoroo.astrid.service.TaskCompleter
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.GrpcProto.Settings
import org.tasks.WearServiceGrpcKt
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.filters.FilterProvider
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter
import org.tasks.themes.ColorProvider
import javax.inject.Inject
@OptIn(ExperimentalHorologistApi::class)
@ -24,6 +29,11 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
@Inject lateinit var preferences: Preferences
@Inject lateinit var taskCompleter: TaskCompleter
@Inject lateinit var headerFormatter: HeaderFormatter
@Inject lateinit var firebase: Firebase
@Inject lateinit var filterProvider: FilterProvider
@Inject lateinit var inventory: Inventory
@Inject lateinit var colorProvider: ColorProvider
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
override val registry: WearDataLayerRegistry by lazy {
applicationContext.wearDataLayerRegistry(lifecycleScope)
@ -40,6 +50,11 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
taskCompleter = taskCompleter,
headerFormatter = headerFormatter,
settings = settings,
firebase = firebase,
filterProvider = filterProvider,
inventory = inventory,
colorProvider = colorProvider,
defaultFilterProvider = defaultFilterProvider,
)
}
}

@ -7,20 +7,32 @@ import kotlinx.coroutines.flow.firstOrNull
import org.tasks.GrpcProto
import org.tasks.GrpcProto.CompleteTaskRequest
import org.tasks.GrpcProto.CompleteTaskResponse
import org.tasks.GrpcProto.GetListsResponse
import org.tasks.GrpcProto.GetTasksRequest
import org.tasks.GrpcProto.ListItem
import org.tasks.GrpcProto.ListItemType
import org.tasks.GrpcProto.Tasks
import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.ToggleGroupResponse
import org.tasks.WearServiceGrpcKt
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.copy
import org.tasks.data.NO_COUNT
import org.tasks.data.isHidden
import org.tasks.filters.AstridOrderingFilter
import org.tasks.filters.Filter
import org.tasks.filters.FilterProvider
import org.tasks.filters.MyTasksFilter
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.filters.getIcon
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter
import org.tasks.tasklist.SectionedDataSource
import org.tasks.tasklist.UiItem
import org.tasks.themes.ColorProvider
import timber.log.Timber
class WearService(
private val taskDao: TaskDao,
@ -29,12 +41,17 @@ class WearService(
private val headerFormatter: HeaderFormatter,
private val settings: DataStore<GrpcProto.Settings>,
private val firebase: Firebase,
private val filterProvider: FilterProvider,
private val inventory: Inventory,
private val colorProvider: ColorProvider,
private val defaultFilterProvider: DefaultFilterProvider,
) : 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() ?: GrpcProto.Settings.getDefaultInstance()
val filter =
defaultFilterProvider.getFilterFromPreference(settingsData.filter.takeIf { it.isNotBlank() })
val preferences = WearPreferences(appPreferences, settingsData)
val collapsed = settingsData?.collapsedList?.toSet() ?: emptySet()
val payload = SectionedDataSource(
@ -57,14 +74,14 @@ class WearService(
is UiItem.Header ->
GrpcProto.UiItem.newBuilder()
.setId(item.value)
.setType(GrpcProto.UiItemType.Header)
.setType(ListItemType.Header)
.setTitle(headerFormatter.headerString(item.value))
.setCollapsed(collapsed.contains(item.value))
.build()
is UiItem.Task ->
GrpcProto.UiItem.newBuilder()
.setType(GrpcProto.UiItemType.Task)
.setType(ListItemType.Item)
.setId(item.task.id)
.setPriority(item.task.priority)
.setCompleted(item.task.isCompleted)
@ -101,7 +118,8 @@ class WearService(
} else {
if (collapsed.contains(request.value)) {
collapsed.clear()
collapsed.addAll(it.collapsedList.toMutableList().apply { remove(request.value) })
collapsed.addAll(
it.collapsedList.toMutableList().apply { remove(request.value) })
}
}
}
@ -118,4 +136,57 @@ class WearService(
taskDao.setCollapsed(request.value, request.collapsed)
return ToggleGroupResponse.newBuilder().build()
}
override suspend fun getLists(request: GrpcProto.GetListsRequest): GetListsResponse {
val position = request.position
val limit = request.limit.takeIf { it > 0 } ?: Int.MAX_VALUE
val selected = settings.data.firstOrNull()?.filter?.takeIf { it.isNotBlank() }
?: defaultFilterProvider.getFilterPreferenceValue(MyTasksFilter.create())
val filters = filterProvider.wearableFilters()
return GetListsResponse.newBuilder()
.setTotalItems(filters.size)
.addAllItems(
filters
.subList(position, (position + limit).coerceAtMost(filters.size))
.map { item ->
when (item) {
is Filter -> {
ListItem.newBuilder()
.setId(defaultFilterProvider.getFilterPreferenceValue(item))
.setType(ListItemType.Item)
.setTitle(item.title ?: "")
.setIcon(item.getIcon(inventory))
.setColor(getColor(item))
.setTaskCount(item.count.takeIf { it != NO_COUNT } ?: try {
taskDao.count(item)
} catch (e: Exception) {
Timber.e(e)
0
})
.build()
}
is NavigationDrawerSubheader ->
ListItem.newBuilder()
.setType(ListItemType.Header)
.setTitle(item.title ?: "")
.setId("${item.subheaderType}_${item.id}")
.build()
else -> throw IllegalArgumentException()
}
}
)
.build()
}
private fun getColor(filter: Filter): Int {
if (filter.tint != 0) {
val color = colorProvider.getThemeColor(filter.tint, true)
if (color.isFree || inventory.purchasedThemes()) {
return color.primaryColor
}
}
return 0
}
}

@ -8,6 +8,7 @@ package com.todoroo.astrid.dao
import com.todoroo.astrid.timers.TimerPlugin
import org.tasks.LocalBroadcastManager
import org.tasks.data.TaskContainer
import org.tasks.data.count
import org.tasks.data.dao.TaskDao
import org.tasks.data.db.SuspendDbUtils.eachChunk
import org.tasks.data.entity.Task
@ -40,6 +41,8 @@ class TaskDao @Inject constructor(
suspend fun fetch(remoteId: String): Task? = taskDao.fetch(remoteId)
suspend fun count(item: Filter): Int = taskDao.count(item)
suspend fun getRecurringTasks(remoteIds: List<String>): List<Task> =
taskDao.getRecurringTasks(remoteIds)

@ -1,6 +1,7 @@
package org.tasks.filters
import org.jetbrains.compose.resources.getString
import org.tasks.compose.drawer.DrawerConfiguration
import org.tasks.data.GoogleTaskFilters
import org.tasks.data.LocationFilters
import org.tasks.data.NO_ORDER
@ -16,11 +17,10 @@ import org.tasks.data.entity.CaldavAccount.Companion.TYPE_LOCAL
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.data.setupLocalAccount
import org.tasks.data.toGtasksFilter
import org.tasks.filters.NavigationDrawerSubheader.SubheaderType
import org.tasks.kmp.IS_DEBUG
import org.tasks.compose.drawer.DrawerConfiguration
import org.tasks.data.toLocationFilter
import org.tasks.data.toTagFilter
import org.tasks.filters.NavigationDrawerSubheader.SubheaderType
import org.tasks.kmp.IS_DEBUG
import org.tasks.preferences.TasksPreferences
import org.tasks.preferences.TasksPreferences.Companion.collapseDebug
import org.tasks.preferences.TasksPreferences.Companion.collapseFilters
@ -59,6 +59,9 @@ class FilterProvider(
suspend fun filterPickerItems(): List<FilterListItem> =
getAllFilters(showCreate = false)
suspend fun wearableFilters(): List<FilterListItem> =
getAllFilters(showCreate = false, forceExpand = true, hideUnused = true)
suspend fun drawerCustomizationItems(): List<FilterListItem> =
getAllFilters(showBuiltIn = false, showCreate = true)

@ -5,13 +5,13 @@ package org.tasks.grpc;
option java_package = "org.tasks";
option java_outer_classname = "GrpcProto";
enum UiItemType {
enum ListItemType {
Header = 0;
Task = 1;
Item = 1;
}
message UiItem {
UiItemType type = 1;
ListItemType type = 1;
uint64 id = 2;
string title = 3;
bool completed = 4;
@ -34,7 +34,7 @@ message LastUpdate {
message Settings {
repeated uint64 collapsed = 1;
string filter = 2;
optional string filter = 2;
bool showHidden = 3;
bool showCompleted = 4;
}
@ -59,10 +59,29 @@ message ToggleGroupRequest {
}
message ToggleGroupResponse {}
message ListItem {
ListItemType type = 1;
string id = 2;
string title = 3;
string icon = 4;
uint32 color = 5;
uint32 taskCount = 6;
}
message GetListsRequest {
uint32 position = 1;
uint32 limit = 2;
}
message GetListsResponse {
uint32 totalItems = 1;
repeated ListItem items = 2;
}
service WearService {
rpc getTasks(GetTasksRequest) returns (Tasks);
rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse);
rpc toggleGroup(ToggleGroupRequest) returns (ToggleGroupResponse);
rpc updateSettings(UpdateSettingsRequest) returns (Settings);
rpc toggleSubtasks(ToggleGroupRequest) returns (ToggleGroupResponse);
rpc getLists(GetListsRequest) returns (GetListsResponse);
}

@ -31,6 +31,8 @@ import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import androidx.wear.tooling.preview.devices.WearDevices
import com.google.android.horologist.compose.layout.AppScaffold
import org.tasks.presentation.screens.MenuScreen
import org.tasks.presentation.screens.MenuViewModel
import org.tasks.presentation.screens.SettingsScreen
import org.tasks.presentation.screens.SettingsViewModel
import org.tasks.presentation.screens.TaskListScreen
@ -54,6 +56,7 @@ class MainActivity : ComponentActivity() {
val navController = rememberSwipeDismissableNavController()
val taskListViewModel: TaskListViewModel = viewModel()
val taskListItems = taskListViewModel.uiItems.collectAsLazyPagingItems()
val settingsViewModel: SettingsViewModel = viewModel()
SwipeDismissableNavHost(
startDestination = "task_list",
navController = navController,
@ -87,14 +90,21 @@ class MainActivity : ComponentActivity() {
}
composable(
route = "menu",
) {
) { navBackStackEntry ->
val menuViewModel: MenuViewModel = viewModel(navBackStackEntry)
MenuScreen(
items = menuViewModel.uiItems.collectAsLazyPagingItems(),
selectFilter = {
settingsViewModel.setFilter(it.id)
navController.popBackStack()
},
)
}
composable(
route = "settings",
) { navBackStackEntry ->
val settingsViewModel: SettingsViewModel = viewModel(navBackStackEntry)
val viewState = settingsViewModel.viewState.collectAsStateWithLifecycle().value
) {
val viewState =
settingsViewModel.viewState.collectAsStateWithLifecycle().value
if (viewState.initialized) {
SettingsScreen(
showHidden = viewState.settings.showHidden,

@ -0,0 +1,33 @@
package org.tasks.presentation.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Card
import androidx.wear.compose.material.MaterialTheme
@Composable
fun Card(
backgroundColor: Color = MaterialTheme.colors.surface,
icon: @Composable () -> Unit = {},
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
Card(
onClick = onClick,
backgroundPainter = ColorPainter(backgroundColor),
contentPadding = PaddingValues(0.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
icon()
content()
}
}
}

@ -0,0 +1,25 @@
package org.tasks.presentation.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Text
@Composable
fun CollapsibleHeader(
title: String,
collapsed: Boolean,
onClick: () -> Unit,
) {
Header(
onClick = onClick,
) {
Text(
text = title,
)
Spacer(modifier = Modifier.width(4.dp))
Chevron(collapsed = collapsed)
}
}

@ -0,0 +1,6 @@
package org.tasks.presentation.components
import androidx.compose.runtime.Composable
@Composable
fun EmptyCard() = Card(onClick = {}) { }

@ -3,10 +3,8 @@ 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
@ -16,10 +14,17 @@ import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
@Composable
fun GroupSeparator(
title: String,
collapsed: Boolean,
onClick: () -> Unit,
fun Header(text: String) {
Header(clickable = false) {
Text(text)
}
}
@Composable
internal fun Header(
clickable: Boolean = true,
onClick: () -> Unit = {},
content: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -27,13 +32,9 @@ fun GroupSeparator(
modifier = Modifier
.clip(MaterialTheme.shapes.large)
.fillMaxWidth()
.clickable(onClick = onClick)
.clickable(enabled = clickable, onClick = onClick)
.padding(12.dp)
) {
Text(
text = title,
)
Spacer(modifier = Modifier.width(4.dp))
Chevron(collapsed = collapsed)
content()
}
}

@ -0,0 +1,97 @@
package org.tasks.presentation.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.compose.layout.ScalingLazyColumn
import com.google.android.horologist.compose.layout.ScreenScaffold
import com.google.android.horologist.compose.layout.rememberResponsiveColumnState
import com.google.android.horologist.compose.paging.items
import org.tasks.GrpcProto.ListItem
import org.tasks.GrpcProto.ListItemType
import org.tasks.compose.components.imageVectorByName
import org.tasks.presentation.components.Card
import org.tasks.presentation.components.EmptyCard
import org.tasks.presentation.components.Header
@OptIn(ExperimentalHorologistApi::class)
@Composable
fun MenuScreen(
items: LazyPagingItems<ListItem>,
selectFilter: (ListItem) -> Unit,
) {
val columnState = rememberResponsiveColumnState()
ScreenScaffold(
scrollState = columnState,
) {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
columnState = columnState,
) {
items(
items = items,
key = { item -> "${item.type}_${item.id}" },
) { item ->
if (item == null) {
EmptyCard()
} else {
when (item.type) {
ListItemType.Header ->
Header(text = item.title)
ListItemType.Item -> {
Card(
icon = {
val icon = imageVectorByName(item.icon)
Box(
modifier = Modifier.size(48.dp),
contentAlignment = Alignment.Center,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when (item.color) {
0 -> MaterialTheme.colors.onSurface
else -> Color(color = item.color)
},
)
}
}
},
onClick = { selectFilter(item) },
) {
Text(
item.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 12.dp).weight(1f),
)
if (item.taskCount > 0) {
Text(
item.taskCount.toString(), // TODO: localize
modifier = Modifier.padding(end = 16.dp)
)
}
}
}
else -> throw IllegalArgumentException()
}
}
}
}
}
}

@ -0,0 +1,74 @@
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 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.datalayer.grpc.GrpcExtensions.grpcClient
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.tasks.GrpcProto
import org.tasks.GrpcProto.LastUpdate
import org.tasks.GrpcProto.ListItem
import org.tasks.GrpcProto.Settings
import org.tasks.WearServiceGrpcKt
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.presentation.MyPagingSource
@OptIn(ExperimentalHorologistApi::class)
class MenuViewModel(
application: Application
): AndroidViewModel(application) {
private var pagingSource: MyPagingSource<ListItem>? = null
val uiItems: Flow<PagingData<ListItem>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = {
MyPagingSource { position, limit ->
wearService
.getLists(
GrpcProto
.GetListsRequest
.newBuilder()
.setPosition(position)
.setLimit(limit)
.build()
)
.let { Pair(it.totalItems, it.itemsList) }
}
.also { pagingSource = it }
}
)
.flow
.cachedIn(viewModelScope)
private val registry = application.wearDataLayerRegistry(viewModelScope)
private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = registry.grpcClient(
nodeId = TargetNodeId.PairedPhone,
coroutineScope = viewModelScope,
) {
WearServiceGrpcKt.WearServiceCoroutineStub(it)
}
init {
registry
.protoFlow<LastUpdate>(TargetNodeId.PairedPhone)
.onEach { invalidate() }
.launchIn(viewModelScope)
registry
.protoFlow<Settings>(TargetNodeId.PairedPhone)
.onEach { invalidate() }
.launchIn(viewModelScope)
}
private fun invalidate() {
pagingSource?.invalidate()
}
}

@ -19,6 +19,7 @@ import org.tasks.SettingsKt
import org.tasks.WearServiceGrpcKt
import org.tasks.copy
import org.tasks.extensions.wearDataLayerRegistry
import org.tasks.tasklist.SectionedDataSource.Companion.HEADER_COMPLETED
data class ViewState(
val initialized: Boolean = false,
@ -65,6 +66,14 @@ class SettingsViewModel(
}
}
fun setFilter(filter: String) {
updateSettings {
this.filter = filter
collapsed.clear()
collapsed.add(HEADER_COMPLETED)
}
}
private fun updateSettings(block: SettingsKt.Dsl.() -> Unit) = viewModelScope.launch {
wearService.updateSettings(
GrpcProto.UpdateSettingsRequest.newBuilder()

@ -33,7 +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.CollapsibleHeader
import org.tasks.presentation.components.EmptyCard
import org.tasks.presentation.components.TaskCard
import tasks.kmp.generated.resources.Res
import tasks.kmp.generated.resources.add_task
@ -75,14 +76,10 @@ fun TaskListScreen(
key = { item -> "${item.type}_${item.id}" },
) { item ->
if (item == null) {
TaskCard(
text = "",
icon = {},
onClick = {},
)
EmptyCard()
} else {
when (item.type) {
GrpcProto.UiItemType.Task ->
GrpcProto.ListItemType.Item ->
Row {
if (item.indent > 0) {
Spacer(modifier = Modifier.width(20.dp * item.indent))
@ -119,8 +116,8 @@ fun TaskListScreen(
)
}
GrpcProto.UiItemType.Header ->
GroupSeparator(
GrpcProto.ListItemType.Header ->
CollapsibleHeader(
title = item.title,
collapsed = item.collapsed,
onClick = { toggleGroup(item.id, !item.collapsed) },

@ -63,11 +63,11 @@ class TaskListViewModel(
init {
registry
.protoFlow<LastUpdate>(TargetNodeId.PairedPhone)
.onEach { pagingSource?.invalidate() }
.onEach { invalidate() }
.launchIn(viewModelScope)
registry
.protoFlow<Settings>(TargetNodeId.PairedPhone)
.onEach { pagingSource?.invalidate() }
.onEach { invalidate() }
.launchIn(viewModelScope)
}
@ -78,17 +78,24 @@ class TaskListViewModel(
.setCollapsed(setCollapsed)
.build()
)
invalidate()
}
fun completeTask(id: Long, completed: Boolean) = viewModelScope.launch {
wearService.completeTask(
CompleteTaskRequest.newBuilder().setId(id).setCompleted(completed).build()
)
invalidate()
}
fun toggleSubtasks(id: Long, collapsed: Boolean) = viewModelScope.launch {
wearService.toggleSubtasks(
ToggleGroupRequest.newBuilder().setValue(id).setCollapsed(collapsed).build()
)
invalidate()
}
private fun invalidate() {
pagingSource?.invalidate()
}
}

Loading…
Cancel
Save