Improve phone feedback for Wear onboarding (#3450)

- Show a loading indicator after sending data
 - Show a Snackbar for success/errors
This commit is contained in:
Joris Pelgröm 2023-04-03 02:20:16 +02:00 committed by GitHub
parent e592c334ea
commit 75c7c08b2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 117 additions and 22 deletions

View file

@ -3,7 +3,6 @@ package io.homeassistant.companion.android.settings.wear
import android.annotation.SuppressLint
import android.app.Application
import android.util.Log
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@ -33,11 +32,14 @@ import io.homeassistant.companion.android.database.server.ServerUserInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import org.burnoutcrew.reorderable.ItemPosition
import java.util.UUID
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@ -61,6 +63,7 @@ class SettingsWearViewModel @Inject constructor(
val hasData = _hasData.asStateFlow()
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated = _isAuthenticated.asStateFlow()
private var authenticateId: String? = null
private var serverId = 0
private var remoteServerId = 0
@ -77,6 +80,9 @@ class SettingsWearViewModel @Inject constructor(
var templateTileRefreshInterval = mutableStateOf(0)
private set
private val _resultSnackbar = MutableSharedFlow<String>()
val resultSnackbar = _resultSnackbar.asSharedFlow()
init {
Wearable.getDataClient(application).addListener(this)
viewModelScope.launch {
@ -101,11 +107,7 @@ class SettingsWearViewModel @Inject constructor(
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send config request to wear", e)
Toast.makeText(
application,
application.getString(commonR.string.failed_wear_config_request),
Toast.LENGTH_LONG
).show()
_resultSnackbar.emit(application.getString(commonR.string.failed_watch_connection))
}
}
}
@ -191,11 +193,7 @@ class SettingsWearViewModel @Inject constructor(
Log.d(TAG, "Successfully sent favorites to wear")
} catch (e: Exception) {
Log.e(TAG, "Failed to send favorites to wear", e)
Toast.makeText(
application,
application.getString(commonR.string.failure_send_favorites_wear),
Toast.LENGTH_SHORT
).show()
_resultSnackbar.emit(application.getString(commonR.string.failure_send_favorites_wear))
}
}
@ -206,7 +204,10 @@ class SettingsWearViewModel @Inject constructor(
deviceTrackingEnabled: Boolean,
notificationsEnabled: Boolean
) {
_hasData.value = false // Show loading indicator
val putDataRequest = PutDataMapRequest.create("/authenticate").run {
authenticateId = UUID.randomUUID().toString()
dataMap.putString("AuthId", authenticateId!!)
dataMap.putString("URL", url)
dataMap.putString("AuthCode", authCode)
dataMap.putString("DeviceName", deviceName)
@ -216,9 +217,16 @@ class SettingsWearViewModel @Inject constructor(
asPutDataRequest()
}
Wearable.getDataClient(getApplication<HomeAssistantApplication>()).putDataItem(putDataRequest).apply {
val app = getApplication<HomeAssistantApplication>()
Wearable.getDataClient(app).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent auth to wear") }
addOnFailureListener { e -> Log.e(TAG, "Failed to send auth to wear", e) }
addOnFailureListener { e ->
Log.e(TAG, "Failed to send auth to wear", e)
_hasData.value = true
viewModelScope.launch {
_resultSnackbar.emit(app.getString(commonR.string.failed_watch_connection))
}
}
}
}
@ -245,6 +253,9 @@ class SettingsWearViewModel @Inject constructor(
"/config" -> {
onLoadConfigFromWear(DataMapItem.fromDataItem(item).dataMap)
}
WearDataMessages.PATH_LOGIN_RESULT -> {
onAuthenticateResult(DataMapItem.fromDataItem(item).dataMap)
}
}
}
}
@ -321,4 +332,20 @@ class SettingsWearViewModel @Inject constructor(
}
}
}
private fun onAuthenticateResult(data: DataMap) = viewModelScope.launch {
val id = data.getString(WearDataMessages.KEY_ID, "")
if (id != authenticateId) return@launch
val success = data.getBoolean(WearDataMessages.KEY_SUCCESS, false)
val application = getApplication<HomeAssistantApplication>()
if (success) {
_resultSnackbar.emit(application.getString(commonR.string.logged_in))
} else {
val e = data.getString(WearDataMessages.LOGIN_RESULT_EXCEPTION, "")
Log.e(TAG, "Watch was unable to register: $e")
_resultSnackbar.emit(application.getString(commonR.string.failed_watch_registration))
}
authenticateId = null
}
}

View file

@ -19,8 +19,10 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -33,6 +35,9 @@ import com.mikepenz.iconics.typeface.library.community.material.CommunityMateria
import io.homeassistant.companion.android.common.data.integration.domain
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
import io.homeassistant.companion.android.util.compose.getEntityDomainString
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
@ -43,7 +48,8 @@ import io.homeassistant.companion.android.common.R as commonR
@Composable
fun LoadWearFavoritesSettings(
settingsWearViewModel: SettingsWearViewModel,
onBackClicked: () -> Unit
onBackClicked: () -> Unit,
events: SharedFlow<String>
) {
val reorderState = rememberReorderableLazyListState(
onMove = { from, to -> settingsWearViewModel.onMove(from, to) },
@ -55,7 +61,17 @@ fun LoadWearFavoritesSettings(
val validEntities = settingsWearViewModel.entities.filter { it.key.split(".")[0] in settingsWearViewModel.supportedDomains }.values.sortedBy { it.entityId }.toList()
val favoriteEntities = settingsWearViewModel.favoriteEntityIds
val scaffoldState = rememberScaffoldState()
LaunchedEffect("snackbar") {
events.onEach { message ->
scaffoldState.snackbarHostState.currentSnackbarData?.dismiss() // in case of rapid-fire events
scaffoldState.snackbarHostState.showSnackbar(message)
}.launchIn(this)
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
SettingsWearTopAppBar(
title = { Text(stringResource(commonR.string.wear_favorite_entities)) },

View file

@ -35,7 +35,8 @@ fun LoadSettingsHomeView(
composable(SettingsWearMainView.FAVORITES) {
LoadWearFavoritesSettings(
settingsWearViewModel = settingsWearViewModel,
onBackClicked = { navController.navigateUp() }
onBackClicked = { navController.navigateUp() },
events = settingsWearViewModel.resultSnackbar
)
}
composable(SettingsWearMainView.LANDING) {
@ -49,7 +50,8 @@ fun LoadSettingsHomeView(
navigateFavorites = { navController.navigate(SettingsWearMainView.FAVORITES) },
navigateTemplateTile = { navController.navigate(SettingsWearMainView.TEMPLATE) },
loginWearOs = loginWearOs,
onBackClicked = onStartBackClicked
onBackClicked = onStartBackClicked,
events = settingsWearViewModel.resultSnackbar
)
}
composable(SettingsWearMainView.TEMPLATE) {

View file

@ -10,7 +10,9 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -20,6 +22,10 @@ import androidx.compose.ui.unit.dp
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.settings.views.SettingsRow
import io.homeassistant.companion.android.util.wearDeviceName
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import io.homeassistant.companion.android.common.R as commonR
@Composable
@ -30,9 +36,18 @@ fun SettingWearLandingView(
navigateFavorites: () -> Unit,
navigateTemplateTile: () -> Unit,
loginWearOs: () -> Unit,
onBackClicked: () -> Unit
onBackClicked: () -> Unit,
events: Flow<String>
) {
val scaffoldState = rememberScaffoldState()
LaunchedEffect("snackbar") {
events.onEach { message ->
scaffoldState.snackbarHostState.showSnackbar(message)
}.launchIn(this)
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
SettingsWearTopAppBar(
title = { Text(stringResource(commonR.string.wear_settings)) },
@ -104,6 +119,7 @@ private fun PreviewSettingWearLandingView() {
navigateFavorites = {},
navigateTemplateTile = {},
loginWearOs = {},
onBackClicked = {}
onBackClicked = {},
events = emptyFlow()
)
}

View file

@ -1,6 +1,10 @@
package io.homeassistant.companion.android.common.util
object WearDataMessages {
const val PATH_LOGIN_RESULT = "/loginResult"
const val KEY_ID = "id"
const val KEY_SUCCESS = "success"
const val KEY_UPDATE_TIME = "UpdateTime"
const val CONFIG_IS_AUTHENTICATED = "isAuthenticated"
@ -15,4 +19,6 @@ object WearDataMessages {
const val CONFIG_FAVORITES = "favorites"
const val CONFIG_TEMPLATE_TILE = "templateTile"
const val CONFIG_TEMPLATE_TILE_REFRESH_INTERVAL = "templateTileRefreshInterval"
const val LOGIN_RESULT_EXCEPTION = "exception"
}

View file

@ -224,10 +224,12 @@
<string name="failed_authentication">Could not authenticate</string>
<string name="failed_connection">Could not connect</string>
<string name="failed_phone_connection">Could not connect to phone</string>
<string name="failed_watch_connection">Could not connect to watch</string>
<string name="failed_registration">Could not register</string>
<string name="failed_watch_registration">Could not register watch</string>
<string name="failed_unsupported">Not supported</string>
<string name="failed_scan">Scanning for Home Assistant failed.</string>
<string name="failure_send_favorites_wear">Failed to send favorites selection to Wear OS</string>
<string name="failure_send_favorites_wear">Failed to send favorites selection to watch</string>
<string name="fans">Fans</string>
<string name="favorite_settings">Favorite Settings</string>
<string name="favorite">Set Favorite Entities</string>
@ -334,6 +336,7 @@
<string name="log_loader_crash">Recent crash</string>
<string name="log">Log</string>
<string name="logbook">Logbook</string>
<string name="logged_in">Logged in</string>
<string name="login">Login</string>
<string name="login_wear_os_device">Login Wear OS Device</string>
<string name="logout">Logout</string>
@ -926,7 +929,6 @@
<string name="widget_text_color_white">White</string>
<string name="widgets">Widgets</string>
<string name="zone_event_failure">Unable to send zone event to Home Assistant</string>
<string name="failed_wear_config_request">Failed to send config request to wear device</string>
<string name="companion_app">Companion App</string>
<string name="basic_sensor_name_last_used_app">Last Used App</string>
<string name="sensor_description_last_used_app">Application name or package name of the last used application on the device</string>

View file

@ -125,13 +125,15 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
}
private fun login(dataMap: DataMap) = mainScope.launch {
var authId = ""
var serverId: Int? = null
try {
authId = dataMap.getString("AuthId", "")
val url = dataMap.getString("URL", "")
val authCode = dataMap.getString("AuthCode", "")
val deviceName = dataMap.getString("DeviceName")
val deviceTrackingEnabled = dataMap.getBoolean("LocationTracking")
val notificationsEnabled = dataMap.getString("Notifications")
val notificationsEnabled = dataMap.getBoolean("Notifications")
val formattedUrl = UrlUtil.formattedUrlString(url)
val server = Server(
@ -154,7 +156,10 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
)
)
serverManager.convertTemporaryServer(serverId)
updateTiles()
launch {
sendLoginResult(authId, true, null)
updateTiles()
}
val intent = HomeActivity.newInstance(applicationContext)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
@ -169,11 +174,32 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
} catch (e: Exception) {
Log.e(TAG, "Can't revoke session", e)
}
launch {
sendLoginResult(authId, false, e.stackTraceToString())
}
}
sendPhoneData()
}
private suspend fun sendLoginResult(id: String?, success: Boolean, exception: String?) {
try {
val putDataRequest = PutDataMapRequest.create(WearDataMessages.PATH_LOGIN_RESULT).run {
dataMap.putString(WearDataMessages.KEY_ID, id ?: "")
dataMap.putBoolean(WearDataMessages.KEY_SUCCESS, success)
if (exception != null) {
dataMap.putString(WearDataMessages.LOGIN_RESULT_EXCEPTION, exception)
}
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(this@PhoneSettingsListener).putDataItem(putDataRequest).await()
Log.d(TAG, "Successfully sent ${WearDataMessages.PATH_LOGIN_RESULT} to device")
} catch (e: Exception) {
Log.w(TAG, "Failed to send ${WearDataMessages.PATH_LOGIN_RESULT} to device", e)
}
}
private fun saveFavorites(dataMap: DataMap) {
val favoritesIds: List<String> =
objectMapper.readValue(dataMap.getString(WearDataMessages.CONFIG_FAVORITES, "[]"))