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

@ -29,6 +29,7 @@ class WearRefresherImpl(
.onEach { nodes -> .onEach { nodes ->
Timber.d("Connected nodes: ${nodes.joinToString()}") Timber.d("Connected nodes: ${nodes.joinToString()}")
watchConnected = nodes.isNotEmpty() watchConnected = nodes.isNotEmpty()
lastUpdate.update()
} }
.launchIn(scope) .launchIn(scope)
} }
@ -39,11 +40,11 @@ class WearRefresherImpl(
override suspend fun refresh() { override suspend fun refresh() {
if (watchConnected) { if (watchConnected) {
lastUpdate.updateData { lastUpdate.update()
it.copy {
now = System.currentTimeMillis()
}
}
} }
} }
} }
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 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 com.todoroo.astrid.service.TaskCreator
import kotlinx.coroutines.flow.firstOrNull 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.GetListsResponse import org.tasks.GrpcProto.GetListsResponse
import org.tasks.GrpcProto.GetTaskResponse
import org.tasks.GrpcProto.GetTasksRequest import org.tasks.GrpcProto.GetTasksRequest
import org.tasks.GrpcProto.ListItem import org.tasks.GrpcProto.ListItem
import org.tasks.GrpcProto.ListItemType import org.tasks.GrpcProto.ListItemType
import org.tasks.GrpcProto.SaveTaskResponse
import org.tasks.GrpcProto.Tasks import org.tasks.GrpcProto.Tasks
import org.tasks.GrpcProto.ToggleGroupRequest import org.tasks.GrpcProto.ToggleGroupRequest
import org.tasks.GrpcProto.ToggleGroupResponse import org.tasks.GrpcProto.ToggleGroupResponse
@ -46,6 +49,7 @@ class WearService(
private val inventory: Inventory, private val inventory: Inventory,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val defaultFilterProvider: DefaultFilterProvider, private val defaultFilterProvider: DefaultFilterProvider,
private val taskCreator: TaskCreator,
) : 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
@ -181,6 +185,38 @@ class WearService(
.build() .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 { private fun getColor(filter: Filter): Int {
if (filter.tint != 0) { if (filter.tint != 0) {
val color = colorProvider.getThemeColor(filter.tint, true) 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-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-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-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" } 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" } 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; 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 { service WearService {
rpc getTasks(GetTasksRequest) returns (Tasks); rpc getTasks(GetTasksRequest) returns (Tasks);
rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse); rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse);
@ -84,4 +100,6 @@ service WearService {
rpc updateSettings(UpdateSettingsRequest) returns (Settings); rpc updateSettings(UpdateSettingsRequest) returns (Settings);
rpc toggleSubtasks(ToggleGroupRequest) returns (ToggleGroupResponse); rpc toggleSubtasks(ToggleGroupRequest) returns (ToggleGroupResponse);
rpc getLists(GetListsRequest) returns (GetListsResponse); 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.material)
implementation(libs.wear.compose.foundation) implementation(libs.wear.compose.foundation)
implementation(libs.wear.compose.navigation) implementation(libs.wear.compose.navigation)
implementation(libs.wear.input)
implementation(libs.wear.tooling.preview) implementation(libs.wear.tooling.preview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)

@ -12,14 +12,11 @@ import androidx.activity.viewModels
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import androidx.wear.tooling.preview.devices.WearDevices
import com.google.android.horologist.compose.layout.AppScaffold import com.google.android.horologist.compose.layout.AppScaffold
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource 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.MenuViewModel
import org.tasks.presentation.screens.SettingsScreen import org.tasks.presentation.screens.SettingsScreen
import org.tasks.presentation.screens.SettingsViewModel 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.TaskListScreen
import org.tasks.presentation.screens.TaskListViewModel import org.tasks.presentation.screens.TaskListViewModel
import org.tasks.presentation.theme.TasksTheme import org.tasks.presentation.theme.TasksTheme
@ -162,8 +161,8 @@ class MainActivity : ComponentActivity() {
onComplete = { id, completed -> onComplete = { id, completed ->
taskListViewModel.completeTask(id, completed) taskListViewModel.completeTask(id, completed)
}, },
openTask = { navController.navigate("task_edit?id=$it") }, openTask = { navController.navigate("task_edit?taskId=$it") },
addTask = { navController.navigate("task_edit")}, addTask = { navController.navigate("task_edit?taskId=0")},
openMenu = { navController.navigate("menu") }, openMenu = { navController.navigate("menu") },
openSettings = { navController.navigate("settings") }, openSettings = { navController.navigate("settings") },
toggleSubtasks = { id, collapsed -> toggleSubtasks = { id, collapsed ->
@ -172,17 +171,28 @@ class MainActivity : ComponentActivity() {
) )
} }
composable( composable(
route = "task_edit?id={taskId}", route = "task_edit?taskId={taskId}",
arguments = listOf( arguments = listOf(
navArgument("taskId") { navArgument("taskId") {
type = NavType.StringType type = NavType.LongType
nullable = true
defaultValue = null
} }
) )
) { ) { navBackStackEntry ->
val taskId = it.arguments?.getString("taskId") val taskId = navBackStackEntry.arguments?.getLong("taskId") ?: 0
WearApp() 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( composable(
route = "menu", 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,7 +96,7 @@ fun TaskListScreen(
modifier = Modifier.padding(top = 16.dp) modifier = Modifier.padding(top = 16.dp)
) )
} }
} } else {
items( items(
items = uiItems, items = uiItems,
key = { item -> "${item.type}_${item.id}_${item.completed}" }, key = { item -> "${item.type}_${item.id}_${item.completed}" },
@ -126,7 +126,12 @@ fun TaskListScreen(
) )
}, },
onClick = { openTask(item.id) }, onClick = { openTask(item.id) },
toggleSubtasks = { toggleSubtasks(item.id, !item.collapsed) }, toggleSubtasks = {
toggleSubtasks(
item.id,
!item.collapsed
)
},
) )
} }
@ -145,6 +150,7 @@ fun TaskListScreen(
} }
} }
} }
}
} }
@Composable @Composable

Loading…
Cancel
Save