diff --git a/app/src/googleplay/java/org/tasks/wear/WearDataService.kt b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt index 20831d141..972393568 100644 --- a/app/src/googleplay/java/org/tasks/wear/WearDataService.kt +++ b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt @@ -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 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.update() { + updateData { it.copy { now = System.currentTimeMillis() } } } \ No newline at end of file diff --git a/app/src/googleplay/java/org/tasks/wear/WearService.kt b/app/src/googleplay/java/org/tasks/wear/WearService.kt index f53c79c1f..d744781a8 100644 --- a/app/src/googleplay/java/org/tasks/wear/WearService.kt +++ b/app/src/googleplay/java/org/tasks/wear/WearService.kt @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4014f13a3..5f17bc771 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/wear-datalayer/src/main/proto/grpc.proto b/wear-datalayer/src/main/proto/grpc.proto index a52fdd1c3..43d33a4a5 100644 --- a/wear-datalayer/src/main/proto/grpc.proto +++ b/wear-datalayer/src/main/proto/grpc.proto @@ -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); } diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 33aea26ac..e0b5f3f24 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -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) diff --git a/wear/src/main/java/org/tasks/presentation/MainActivity.kt b/wear/src/main/java/org/tasks/presentation/MainActivity.kt index 2bb85c5ae..702fd3651 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivity.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt @@ -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() -} diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskEditScreen.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskEditScreen.kt new file mode 100644 index 000000000..e00c0a628 --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskEditScreen.kt @@ -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 = listOf( + RemoteInput + .Builder("input") + .setLabel("Enter title") + .setAllowFreeFormInput(true) + .wearableExtender { + setInputActionType(EditorInfo.IME_ACTION_DONE) + } + .build() + ) + RemoteInputIntentHelper.putRemoteInputsExtra(intent, remoteInputs) + keyboardInputRequest.launch(intent) + } + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskEditViewModel.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskEditViewModel.kt new file mode 100644 index 000000000..e247d02af --- /dev/null +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskEditViewModel.kt @@ -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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(TaskEditViewModel::class.java)) { + return TaskEditViewModel( + applicationContext = applicationContext, + taskId = taskId, + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt index c17084823..661d11b29 100644 --- a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt +++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt @@ -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}") + } } } }