WearOS task edit screen - WIP

pull/2983/head
Alex Baker 1 year ago
parent b6f1722350
commit 4e7e05c8af

@ -8,6 +8,7 @@ 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 com.todoroo.astrid.service.TaskCreator
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.GrpcProto.Settings
import org.tasks.WearServiceGrpcKt
@ -34,6 +35,7 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
@Inject lateinit var inventory: Inventory
@Inject lateinit var colorProvider: ColorProvider
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var taskCreator: TaskCreator
override val registry: WearDataLayerRegistry by lazy {
applicationContext.wearDataLayerRegistry(lifecycleScope)
@ -55,6 +57,7 @@ class WearDataService : BaseGrpcDataService<WearServiceGrpcKt.WearServiceCorouti
inventory = inventory,
colorProvider = colorProvider,
defaultFilterProvider = defaultFilterProvider,
taskCreator = taskCreator,
)
}
}

@ -29,6 +29,7 @@ class WearRefresherImpl(
.onEach { nodes ->
Timber.d("Connected nodes: ${nodes.joinToString()}")
watchConnected = nodes.isNotEmpty()
lastUpdate.update()
}
.launchIn(scope)
}
@ -39,11 +40,11 @@ class WearRefresherImpl(
override suspend fun refresh() {
if (watchConnected) {
lastUpdate.updateData {
it.copy {
now = System.currentTimeMillis()
}
}
lastUpdate.update()
}
}
}
private suspend fun DataStore<LastUpdate>.update() {
updateData { it.copy { now = System.currentTimeMillis() } }
}

@ -3,14 +3,17 @@ package org.tasks.wear
import androidx.datastore.core.DataStore
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskCompleter
import com.todoroo.astrid.service.TaskCreator
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.GetTaskResponse
import org.tasks.GrpcProto.GetTasksRequest
import org.tasks.GrpcProto.ListItem
import org.tasks.GrpcProto.ListItemType
import org.tasks.GrpcProto.SaveTaskResponse
import org.tasks.GrpcProto.Tasks
import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.ToggleGroupResponse
@ -46,6 +49,7 @@ class WearService(
private val inventory: Inventory,
private val colorProvider: ColorProvider,
private val defaultFilterProvider: DefaultFilterProvider,
private val taskCreator: TaskCreator,
) : WearServiceGrpcKt.WearServiceCoroutineImplBase() {
override suspend fun getTasks(request: GetTasksRequest): Tasks {
val position = request.position
@ -181,6 +185,38 @@ class WearService(
.build()
}
override suspend fun getTask(request: GrpcProto.GetTaskRequest): GetTaskResponse {
Timber.d("getTask($request)")
val task = taskDao.fetch(request.taskId)
?: throw IllegalArgumentException()
return GetTaskResponse.newBuilder()
.setTitle(task.title ?: "")
.setCompleted(task.isCompleted)
.setPriority(task.priority)
.setRepeating(task.isRecurring)
.build()
}
override suspend fun saveTask(request: GrpcProto.SaveTaskRequest): SaveTaskResponse {
Timber.d("saveTask($request)")
if (request.taskId == 0L) {
taskCreator
.basicQuickAddTask(request.title)
.apply {
}
.let { taskDao.save(it) }
} else {
taskDao.fetch(request.taskId)?.let { task ->
taskDao.save(
task.copy(
title = request.title,
)
)
}
}
return SaveTaskResponse.newBuilder().build()
}
private fun getColor(filter: Filter): Int {
if (filter.tint != 0) {
val color = colorProvider.getThemeColor(filter.tint, true)

@ -188,6 +188,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
wear-input = { group = "androidx.wear", name = "wear-input", version = "1.2.0-alpha02" }
wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version = "1.0.0" }
protobuf-protoc-gen-grpc-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc" }

@ -77,6 +77,22 @@ message GetListsResponse {
repeated ListItem items = 2;
}
message GetTaskRequest {
uint64 taskId = 1;
}
message GetTaskResponse {
string title = 1;
bool completed = 2;
bool repeating = 3;
uint32 priority = 4;
}
message SaveTaskRequest {
uint64 taskId = 1;
string title = 2;
}
message SaveTaskResponse {}
service WearService {
rpc getTasks(GetTasksRequest) returns (Tasks);
rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse);
@ -84,4 +100,6 @@ service WearService {
rpc updateSettings(UpdateSettingsRequest) returns (Settings);
rpc toggleSubtasks(ToggleGroupRequest) returns (ToggleGroupResponse);
rpc getLists(GetListsRequest) returns (GetListsResponse);
rpc getTask(GetTaskRequest) returns (GetTaskResponse);
rpc saveTask(SaveTaskRequest) returns (SaveTaskResponse);
}

@ -78,6 +78,7 @@ dependencies {
implementation(libs.wear.compose.material)
implementation(libs.wear.compose.foundation)
implementation(libs.wear.compose.navigation)
implementation(libs.wear.input)
implementation(libs.wear.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen)

@ -12,14 +12,11 @@ import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@ -34,7 +31,6 @@ import androidx.wear.compose.material.TimeText
import androidx.wear.compose.navigation.SwipeDismissableNavHost
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 kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
@ -42,6 +38,9 @@ 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.TaskEditScreen
import org.tasks.presentation.screens.TaskEditViewModel
import org.tasks.presentation.screens.TaskEditViewModelFactory
import org.tasks.presentation.screens.TaskListScreen
import org.tasks.presentation.screens.TaskListViewModel
import org.tasks.presentation.theme.TasksTheme
@ -162,8 +161,8 @@ class MainActivity : ComponentActivity() {
onComplete = { id, completed ->
taskListViewModel.completeTask(id, completed)
},
openTask = { navController.navigate("task_edit?id=$it") },
addTask = { navController.navigate("task_edit")},
openTask = { navController.navigate("task_edit?taskId=$it") },
addTask = { navController.navigate("task_edit?taskId=0")},
openMenu = { navController.navigate("menu") },
openSettings = { navController.navigate("settings") },
toggleSubtasks = { id, collapsed ->
@ -172,17 +171,28 @@ class MainActivity : ComponentActivity() {
)
}
composable(
route = "task_edit?id={taskId}",
route = "task_edit?taskId={taskId}",
arguments = listOf(
navArgument("taskId") {
type = NavType.StringType
nullable = true
defaultValue = null
type = NavType.LongType
}
)
) {
val taskId = it.arguments?.getString("taskId")
WearApp()
) { navBackStackEntry ->
val taskId = navBackStackEntry.arguments?.getLong("taskId") ?: 0
val context = LocalContext.current
val viewModel: TaskEditViewModel = viewModel(
viewModelStoreOwner = navBackStackEntry,
factory = TaskEditViewModelFactory(
applicationContext = context.applicationContext,
taskId = taskId,
)
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
TaskEditScreen(
uiState = uiState,
setTitle = { viewModel.setTitle(it) },
save = { viewModel.save { navController.popBackStack() } },
)
}
composable(
route = "menu",
@ -218,34 +228,3 @@ class MainActivity : ComponentActivity() {
}
}
}
@Composable
fun WearApp() {
TasksTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
TimeText()
Greeting()
}
}
}
@Composable
fun Greeting() {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary,
text = "Coming soon!"
)
}
@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true)
@Composable
fun DefaultPreview() {
WearApp()
}

@ -0,0 +1,123 @@
package org.tasks.presentation.screens
import android.app.Activity.RESULT_OK
import android.app.RemoteInput
import android.content.Intent
import android.view.inputmethod.EditorInfo
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.CircularProgressIndicator
import androidx.wear.compose.material.Text
import androidx.wear.input.RemoteInputIntentHelper
import androidx.wear.input.wearableExtender
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.fillMaxRectangle
import com.google.android.horologist.compose.layout.rememberResponsiveColumnState
import org.tasks.presentation.components.Card
import org.tasks.presentation.components.Checkbox
@OptIn(ExperimentalHorologistApi::class)
@Composable
fun TaskEditScreen(
uiState: UiState,
setTitle: (String) -> Unit,
save: () -> Unit,
) {
if (uiState.loading) {
Box(
modifier = Modifier.fillMaxRectangle(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
val columnState = rememberResponsiveColumnState(
verticalArrangement = Arrangement.spacedBy(12.dp)
)
val keyboardInputRequest = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
if (result.resultCode == RESULT_OK) {
val text =
result
.data
?.let { RemoteInput.getResultsFromIntent(it) }
?.getCharSequence("input")
?: return@rememberLauncherForActivityResult
setTitle(text.toString())
}
}
ScreenScaffold(
scrollState = columnState,
) {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
columnState = columnState,
) {
item {
Text("New task")
}
item {
Card(
icon = {
Checkbox(
completed = uiState.completed,
repeating = uiState.repeating,
priority = uiState.priority,
toggleComplete = {},
)
},
content = {
Text(
text = uiState.title,
modifier = Modifier.padding(vertical = 4.dp),
)
},
onClick = {},
)
}
item {
Button(
onClick = { save() },
colors = ButtonDefaults.buttonColors(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Save")
}
}
}
}
LaunchedEffect(Unit) {
if (uiState.isNew) {
val intent: Intent = RemoteInputIntentHelper.createActionRemoteInputIntent()
val remoteInputs: List<RemoteInput> = listOf(
RemoteInput
.Builder("input")
.setLabel("Enter title")
.setAllowFreeFormInput(true)
.wearableExtender {
setInputActionType(EditorInfo.IME_ACTION_DONE)
}
.build()
)
RemoteInputIntentHelper.putRemoteInputsExtra(intent, remoteInputs)
keyboardInputRequest.launch(intent)
}
}
}
}

@ -0,0 +1,92 @@
package org.tasks.presentation.screens
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.data.TargetNodeId
import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.tasks.GrpcProto
import org.tasks.WearServiceGrpcKt
import org.tasks.extensions.wearDataLayerRegistry
import timber.log.Timber
data class UiState(
val isNew: Boolean,
val loading: Boolean = !isNew,
val completed: Boolean = false,
val repeating: Boolean = false,
val priority: Int = 0,
val title: String = "",
)
@OptIn(ExperimentalHorologistApi::class)
class TaskEditViewModel(
applicationContext: Context,
private val taskId: Long,
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(isNew = taskId == 0L))
val uiState = _uiState.asStateFlow()
private val registry = applicationContext.wearDataLayerRegistry(viewModelScope)
private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = registry.grpcClient(
nodeId = TargetNodeId.PairedPhone,
coroutineScope = viewModelScope,
) {
WearServiceGrpcKt.WearServiceCoroutineStub(it)
}
init {
if (taskId > 0) {
viewModelScope.launch {
val task = wearService
.getTask(GrpcProto.GetTaskRequest.newBuilder().setTaskId(taskId).build())
Timber.d("Received $task")
_uiState.update {
it.copy(
loading = false,
completed = task.completed,
title = task.title,
repeating = task.repeating,
priority = task.priority,
)
}
}
}
}
fun save(onComplete: () -> Unit) = viewModelScope.launch {
val state = uiState.value
wearService.saveTask(
GrpcProto.SaveTaskRequest.newBuilder()
.setTitle(state.title)
.build()
)
onComplete()
}
fun setTitle(title: String) {
_uiState.update { it.copy(title = title) }
}
}
class TaskEditViewModelFactory(
private val applicationContext: Context,
private val taskId: Long,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(TaskEditViewModel::class.java)) {
return TaskEditViewModel(
applicationContext = applicationContext,
taskId = taskId,
) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

@ -96,49 +96,55 @@ fun TaskListScreen(
modifier = Modifier.padding(top = 16.dp)
)
}
}
items(
items = uiItems,
key = { item -> "${item.type}_${item.id}_${item.completed}" },
) { item ->
if (item == null) {
EmptyCard()
} else {
when (item.type) {
GrpcProto.ListItemType.Item ->
Row {
if (item.indent > 0) {
Spacer(modifier = Modifier.width(20.dp * item.indent))
} else {
items(
items = uiItems,
key = { item -> "${item.type}_${item.id}_${item.completed}" },
) { item ->
if (item == null) {
EmptyCard()
} else {
when (item.type) {
GrpcProto.ListItemType.Item ->
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 = {
Checkbox(
completed = item.completed,
repeating = item.repeating,
priority = item.priority,
toggleComplete = {
onComplete(item.id, !item.completed)
}
)
},
onClick = { openTask(item.id) },
toggleSubtasks = {
toggleSubtasks(
item.id,
!item.collapsed
)
},
)
}
TaskCard(
text = item.title,
hidden = item.hidden,
subtasksCollapsed = item.collapsed,
numSubtasks = item.numSubtasks,
icon = {
Checkbox(
completed = item.completed,
repeating = item.repeating,
priority = item.priority,
toggleComplete = {
onComplete(item.id, !item.completed)
}
)
},
onClick = { openTask(item.id) },
toggleSubtasks = { toggleSubtasks(item.id, !item.collapsed) },
)
}
GrpcProto.ListItemType.Header ->
CollapsibleHeader(
title = item.title,
collapsed = item.collapsed,
onClick = { toggleGroup(item.id, !item.collapsed) },
)
GrpcProto.ListItemType.Header ->
CollapsibleHeader(
title = item.title,
collapsed = item.collapsed,
onClick = { toggleGroup(item.id, !item.collapsed) },
)
else -> {
throw IllegalStateException("Unknown item type: ${item.type}")
else -> {
throw IllegalStateException("Unknown item type: ${item.type}")
}
}
}
}

Loading…
Cancel
Save