From 4409a2cb54b9dbfc25123a2ae0abcc7b88a69f32 Mon Sep 17 00:00:00 2001 From: Gustavo Pagani Date: Tue, 23 Jan 2024 14:16:18 +0000 Subject: [PATCH] Add sample screen for connectedAndInstalledNodes function (#1999) --- .../sample/screens/nodes/NodesScreen.kt | 2 +- .../nodeslistener/NodesListenerScreen.kt | 20 ++- .../phone/src/main/res/values/strings.xml | 4 +- .../horologist/datalayer/sample/Screen.kt | 1 + .../horologist/datalayer/sample/WearApp.kt | 8 + .../datalayer/sample/screens/MainScreen.kt | 1 + .../screens/nodes/DataLayerNodesScreen.kt | 3 +- .../screens/nodesactions/NodeDetailsScreen.kt | 5 +- .../nodeslistener/NodesListenerScreen.kt | 165 ++++++++++++++++++ .../nodeslistener/NodesListenerViewModel.kt | 76 ++++++++ .../wear/src/main/res/values/strings.xml | 6 + 11 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt create mode 100644 datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerViewModel.kt diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt index 715fa804be..a3893fa1cb 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt @@ -230,7 +230,7 @@ fun NodesScreenPreview() { @Preview(showBackground = true) @Composable -fun NodesActionsScreenPreviewEmptyNodes() { +fun NodesScreenPreviewEmptyNodes() { NodesScreen( state = NodesScreenState.Loaded(emptyList()), onRefreshClick = { }, diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt index e5c1e1f2a7..f0b67c85f0 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt @@ -71,7 +71,7 @@ fun NodesListenerScreen( item { Text( text = stringResource(id = R.string.nodes_listener_screen_header), - modifier = Modifier.padding(vertical = 10.dp), + modifier = Modifier.padding(10.dp), style = MaterialTheme.typography.titleLarge, ) } @@ -80,7 +80,7 @@ fun NodesListenerScreen( item { Text( text = stringResource(id = R.string.nodes_listener_screen_message), - modifier = Modifier.padding(vertical = 10.dp), + modifier = Modifier.padding(10.dp), style = MaterialTheme.typography.bodyLarge, ) } @@ -165,6 +165,22 @@ fun NodesListenerScreenPreview() { ) } +@Preview(showBackground = true) +@Composable +fun NodesListenerScreenPreviewEmptyNodes() { + NodesListenerScreen( + state = NodesListenerScreenState.Loaded( + nodeList = emptySet(), + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun NodesListenerScreenPreviewApiNotAvailable() { + NodesListenerScreen(state = NodesListenerScreenState.ApiNotAvailable) +} + private class NodePreviewImpl( private val displayName: String, private val id: String, diff --git a/datalayer/sample/phone/src/main/res/values/strings.xml b/datalayer/sample/phone/src/main/res/values/strings.xml index f734313bcd..4fa01944fa 100644 --- a/datalayer/sample/phone/src/main/res/values/strings.xml +++ b/datalayer/sample/phone/src/main/res/values/strings.xml @@ -58,10 +58,10 @@ Start remote activity - Nodes + Nodes Listener This screen shows all the connected nodes that have the app installed. The list updates automatically. No nodes were found. - Node: %1$s + Name: %1$s ID: %1$s diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/Screen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/Screen.kt index dbb352e9db..c79cd965bf 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/Screen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/Screen.kt @@ -29,6 +29,7 @@ sealed class Screen( data object AppHelperTrackingScreen : Screen("appHelperTrackingScreen") data object AppHelperNodesActionsScreen : Screen("appHelperNodesActionsScreen") + data object AppHelperNodesListenerScreen : Screen("appHelperNodesListenerScreen") data object AppHelperNodeDetailsScreen : Screen(nodeDetailsScreenRoute) diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/WearApp.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/WearApp.kt index a03657d32d..b9bdb843f3 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/WearApp.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/WearApp.kt @@ -37,6 +37,7 @@ import com.google.android.horologist.datalayer.sample.screens.nodes.DataLayerNod import com.google.android.horologist.datalayer.sample.screens.nodesactions.NodesActionsScreen import com.google.android.horologist.datalayer.sample.screens.nodesactions.navigateToNodeDetailsScreen import com.google.android.horologist.datalayer.sample.screens.nodesactions.nodeDetailsScreen +import com.google.android.horologist.datalayer.sample.screens.nodeslistener.NodesListenerScreen import com.google.android.horologist.datalayer.sample.screens.tracking.TrackingScreen @Composable @@ -96,6 +97,13 @@ fun WearApp( ) } } + composable(route = Screen.AppHelperNodesListenerScreen.route) { + val columnState = rememberColumnState() + + ScreenScaffold(scrollState = columnState) { + NodesListenerScreen(columnState = columnState) + } + } nodeDetailsScreen() infoScreen( onDismissClick = navController::popBackStack, diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/MainScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/MainScreen.kt index 5266040c1b..8ad152b4da 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/MainScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/MainScreen.kt @@ -52,6 +52,7 @@ private fun SectionedListScope.appHelpersSection(navigateToRoute: (String) -> Un listOf( Pair(R.string.main_menu_apphelpers_tracking_item, Screen.AppHelperTrackingScreen.route), Pair(R.string.main_menu_apphelpers_nodes_actions_item, Screen.AppHelperNodesActionsScreen.route), + Pair(R.string.main_menu_apphelpers_nodes_listener_item, Screen.AppHelperNodesListenerScreen.route), ), ) { header { diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt index 5f93a9fb7a..71e06075d8 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt @@ -47,8 +47,9 @@ fun DataLayerNodesScreen( } items(state.nodes) { Chip( - label = "${it.displayName}(${it.id}) ${if (it.isNearby) "NEAR" else ""}", + label = it.displayName, onClick = { }, + secondaryLabel = "${it.id} ${if (it.isNearby) "(NEAR)" else ""}", ) } item { diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt index 5d869d2093..a82370f4b1 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt @@ -45,6 +45,7 @@ import com.google.android.horologist.compose.layout.belowTimeTextPreview import com.google.android.horologist.compose.material.Chip import com.google.android.horologist.compose.material.Confirmation import com.google.android.horologist.compose.material.Icon +import com.google.android.horologist.compose.material.Title import com.google.android.horologist.compose.material.util.DECORATIVE_ELEMENT_CONTENT_DESCRIPTION import com.google.android.horologist.datalayer.sample.R import com.google.android.horologist.images.base.paintable.ImageVectorPaintable @@ -93,8 +94,8 @@ fun NodeDetailsScreen( modifier = modifier.fillMaxSize(), ) { item { - Text( - text = stringResource(id = R.string.node_details_header), + Title( + textId = R.string.node_details_header, modifier = Modifier.padding(bottom = 10.dp), ) } diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt new file mode 100644 index 0000000000..dc00fd76cd --- /dev/null +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.datalayer.sample.screens.nodeslistener + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.google.android.gms.wearable.Node +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.layout.belowTimeTextPreview +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.Title +import com.google.android.horologist.datalayer.sample.R + +@Composable +fun NodesListenerScreen( + columnState: ScalingLazyColumnState, + modifier: Modifier = Modifier, + viewModel: NodesListenerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + if (state == NodesListenerScreenState.Idle) { + SideEffect { + viewModel.initialize() + } + } + + NodesListenerScreen( + state = state, + columnState = columnState, + modifier = modifier, + ) +} + +@Composable +fun NodesListenerScreen( + state: NodesListenerScreenState, + columnState: ScalingLazyColumnState, + modifier: Modifier = Modifier, +) { + ScalingLazyColumn( + columnState = columnState, + modifier = modifier.fillMaxSize(), + ) { + item { + Title( + textId = R.string.nodes_listener_screen_header, + modifier = Modifier.padding(bottom = 10.dp), + ) + } + when (state) { + NodesListenerScreenState.Idle, + NodesListenerScreenState.Loading, + -> { + item { + CircularProgressIndicator() + } + } + + is NodesListenerScreenState.Loaded -> { + if (state.nodeList.isNotEmpty()) { + item { + Text(stringResource(id = R.string.nodes_listener_screen_message)) + } + items(state.nodeList.toList()) { node -> + Chip( + label = node.displayName, + onClick = { /* do nothing */ }, + secondaryLabel = node.id, + ) + } + } else { + item { + Text(stringResource(id = R.string.nodes_listener_screen_no_nodes)) + } + } + } + + NodesListenerScreenState.ApiNotAvailable -> { + item { + Text(stringResource(id = R.string.wearable_message_api_unavailable)) + } + } + } + } +} + +@WearPreviewDevices +@Composable +fun NodesListenerScreenPreviewLoaded() { + NodesListenerScreen( + state = NodesListenerScreenState.Loaded( + nodeList = setOf( + NodePreviewImpl( + displayName = "Google Pixel Watch", + id = "903b8371", + isNearby = true, + ), + NodePreviewImpl( + displayName = "Galaxy Watch4 Classic", + id = "813d1812", + isNearby = false, + ), + ), + ), + columnState = belowTimeTextPreview(), + ) +} + +@WearPreviewDevices +@Composable +fun NodesListenerScreenPreviewEmptyNodes() { + NodesListenerScreen( + state = NodesListenerScreenState.Loaded(emptySet()), + columnState = belowTimeTextPreview(), + ) +} + +@WearPreviewDevices +@Composable +fun NodesListenerScreenPreviewApiNotAvailable() { + NodesListenerScreen( + state = NodesListenerScreenState.ApiNotAvailable, + columnState = belowTimeTextPreview(), + ) +} + +private class NodePreviewImpl( + private val displayName: String, + private val id: String, + private val isNearby: Boolean, +) : Node { + override fun getDisplayName(): String = displayName + + override fun getId(): String = id + + override fun isNearby(): Boolean = isNearby +} diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerViewModel.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerViewModel.kt new file mode 100644 index 0000000000..1568799cd3 --- /dev/null +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerViewModel.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.datalayer.sample.screens.nodeslistener + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.Node +import com.google.android.horologist.datalayer.watch.WearDataLayerAppHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NodesListenerViewModel + @Inject + constructor( + private val wearDataLayerAppHelper: WearDataLayerAppHelper, + ) : ViewModel() { + + private var initializeCalled = false + + private val _uiState = + MutableStateFlow(NodesListenerScreenState.Idle) + public val uiState: StateFlow = _uiState + + @MainThread + fun initialize() { + if (initializeCalled) return + initializeCalled = true + + _uiState.value = NodesListenerScreenState.Loading + + viewModelScope.launch { + if (!wearDataLayerAppHelper.isAvailable()) { + _uiState.value = NodesListenerScreenState.ApiNotAvailable + } else { + loadNodes() + } + } + } + + private suspend fun loadNodes() { + _uiState.value = NodesListenerScreenState.Loading + + wearDataLayerAppHelper.connectedAndInstalledNodes.collect { + _uiState.value = NodesListenerScreenState.Loaded(nodeList = it) + } + } + } + +sealed class NodesListenerScreenState { + data object Idle : NodesListenerScreenState() + + data object Loading : NodesListenerScreenState() + + data class Loaded(val nodeList: Set) : NodesListenerScreenState() + + data object ApiNotAvailable : NodesListenerScreenState() +} diff --git a/datalayer/sample/wear/src/main/res/values/strings.xml b/datalayer/sample/wear/src/main/res/values/strings.xml index b72bacf45a..23abcfa645 100644 --- a/datalayer/sample/wear/src/main/res/values/strings.xml +++ b/datalayer/sample/wear/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ App Helpers Tracking Nodes actions + Nodes listener Open the datalayer sample on your phone to observe how the counter number is incremented. @@ -70,6 +71,11 @@ Success! Failed: \n%1$s + + Nodes Listener + This screen shows all the connected nodes that have the app installed.\nOn the watch, there should be only a single connected node: the paired phone.\nThe list updates automatically. + No nodes were found. + This is a sample activity to demonstrate it being launched remotely from the phone. \ No newline at end of file