diff --git a/kmp/src/commonMain/composeResources/values/strings.xml b/kmp/src/commonMain/composeResources/values/strings.xml index ea22763c5..22ffc9f86 100644 --- a/kmp/src/commonMain/composeResources/values/strings.xml +++ b/kmp/src/commonMain/composeResources/values/strings.xml @@ -31,4 +31,6 @@ Add task Show unstarted Show completed + Install on phone + Unknown error \ No newline at end of file diff --git a/wear/src/main/java/org/tasks/presentation/MainActivity.kt b/wear/src/main/java/org/tasks/presentation/MainActivity.kt index 092d97615..8edb0073a 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivity.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt @@ -8,14 +8,17 @@ package org.tasks.presentation import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,6 +26,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.paging.compose.collectAsLazyPagingItems +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText @@ -31,6 +36,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 kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource import org.tasks.presentation.screens.MenuScreen import org.tasks.presentation.screens.MenuViewModel import org.tasks.presentation.screens.SettingsScreen @@ -38,8 +45,13 @@ import org.tasks.presentation.screens.SettingsViewModel import org.tasks.presentation.screens.TaskListScreen import org.tasks.presentation.screens.TaskListViewModel import org.tasks.presentation.theme.TasksTheme +import tasks.kmp.generated.resources.Res +import tasks.kmp.generated.resources.wear_install_app +import tasks.kmp.generated.resources.wear_unknown_error class MainActivity : ComponentActivity() { + private val viewModel: MainActivityViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -57,10 +69,90 @@ class MainActivity : ComponentActivity() { val taskListViewModel: TaskListViewModel = viewModel() val taskListItems = taskListViewModel.uiItems.collectAsLazyPagingItems() val settingsViewModel: SettingsViewModel = viewModel() + val connected = viewModel.uiState.collectAsStateWithLifecycle().value + LaunchedEffect(connected) { + when (connected) { + NodesActionScreenState.ApiNotAvailable -> { + navController.popBackStack() + navController.navigate("error") + } + + is NodesActionScreenState.Loaded -> { + navController.popBackStack() + if (connected.nodeList.any { it.type == NodeTypeUiModel.PHONE && it.appInstalled }) { + navController.navigate("task_list") + } else { + connected + .nodeList + .firstOrNull { it.type == NodeTypeUiModel.UNKNOWN } + ?.let { + navController.navigate("error?type=${Errors.APP_NOT_INSTALLED},nodeId=${it.id}") + } + ?: navController.navigate("error") + } + } + + else -> {} + } + } SwipeDismissableNavHost( - startDestination = "task_list", + startDestination = "loading", navController = navController, ) { + composable("loading") { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + composable( + route = "error?type={type},nodeId={nodeId}", + arguments = listOf( + navArgument("type") { + type = NavType.EnumType(Errors::class.java) + defaultValue = Errors.UNKNOWN + }, + navArgument("nodeId") { + type = NavType.StringType + nullable = true + } + ) + ) { + val type = it.arguments?.getSerializable("type") as? Errors + val nodeId = it.arguments?.getString("nodeId") + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (type) { + Errors.APP_NOT_INSTALLED -> { + LaunchedEffect(Unit) { + while (true) { + delay(5000) + viewModel.loadNodes() + } + } + Chip( + onClick = { viewModel.installOnNode(nodeId) }, + label = { + Text( + text = stringResource(Res.string.wear_install_app), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + ) + } + + else -> + Text( + text = stringResource(Res.string.wear_unknown_error), + ) + } + } + } composable("task_list") { TaskListScreen( uiItems = taskListItems, diff --git a/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt b/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt index e47ce2448..a99e146d9 100644 --- a/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt +++ b/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt @@ -1,4 +1,108 @@ package org.tasks.presentation -class MainActivityViewModel { -} \ No newline at end of file +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.data.WearDataLayerRegistry +import com.google.android.horologist.data.apphelper.AppInstallationStatus +import com.google.android.horologist.data.apphelper.AppInstallationStatusNodeType +import com.google.android.horologist.datalayer.watch.WearDataLayerAppHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.tasks.extensions.wearDataLayerRegistry + +@OptIn(ExperimentalHorologistApi::class) +class MainActivityViewModel( + application: Application, +) : AndroidViewModel(application) { + fun installOnNode(nodeId: String?) = viewModelScope.launch { + if (nodeId == null) { + Log.d("MainActivityViewModel", "Missing nodeId") + } else { + helper.installOnNode(nodeId) + } + } + + private val _uiState = + MutableStateFlow(NodesActionScreenState.Idle) + val uiState = _uiState.asStateFlow() + private val registry: WearDataLayerRegistry by lazy { + application.wearDataLayerRegistry(viewModelScope) + } + private val helper: WearDataLayerAppHelper by lazy { + WearDataLayerAppHelper( + context = application, + registry = registry, + scope = viewModelScope, + ) + } + + init { + _uiState.value = NodesActionScreenState.Loading + + loadNodes() + } + + fun loadNodes() = viewModelScope.launch { + if (!helper.isAvailable()) { + _uiState.value = NodesActionScreenState.ApiNotAvailable + } else { + _uiState.value = NodesActionScreenState.Loaded( + nodeList = helper.connectedNodes().map { node -> + val type = when (node.appInstallationStatus) { + is AppInstallationStatus.Installed -> { + val status = + node.appInstallationStatus as AppInstallationStatus.Installed + when (status.nodeType) { + AppInstallationStatusNodeType.WATCH -> NodeTypeUiModel.WATCH + AppInstallationStatusNodeType.PHONE -> NodeTypeUiModel.PHONE + } + } + + AppInstallationStatus.NotInstalled -> NodeTypeUiModel.UNKNOWN + } + + NodeUiModel( + id = node.id, + name = node.displayName, + appInstalled = node.appInstallationStatus is AppInstallationStatus.Installed, + type = type, + ) + }, + ).also { + Log.d("MainActivityViewModel", "Loaded: $it") + } + } + } +} + +data class NodeUiModel( + val id: String, + val name: String, + val appInstalled: Boolean, + val type: NodeTypeUiModel, +) + +enum class NodeTypeUiModel { + WATCH, + PHONE, + UNKNOWN, +} + +enum class Errors { + APP_NOT_INSTALLED, + UNKNOWN +} + +sealed class NodesActionScreenState { + data object Idle : NodesActionScreenState() + + data object Loading : NodesActionScreenState() + + data class Loaded(val nodeList: List) : NodesActionScreenState() + + data object ApiNotAvailable : NodesActionScreenState() +}