diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt index b652b779..ab6d03e9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt @@ -42,6 +42,7 @@ import javax.inject.Singleton import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.service.BluetoothScannerManager import com.health.openscale.core.service.BleConnector +import com.health.openscale.ui.shared.SnackbarEvent /** * Facade responsible for orchestrating Bluetooth operations. @@ -71,15 +72,11 @@ class BluetoothFacade @Inject constructor( scope = scope, scaleFactory = scaleFactory, databaseRepository = databaseRepository, - getCurrentScaleUser = { currentBtScaleUser.value }, - onSavePreferredDevice = { address, name -> - scope.launch { _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_scale_saved_as_preferred, listOf(name))) } - }, - onSnackbarText = { message, duration -> - scope.launch { _oneShotText.emit(OneShotText(message, duration)) } - } + getCurrentScaleUser = { currentBtScaleUser.value } ) + val snackbarEventsFromConnector: SharedFlow = connection.snackbarEvents + // --- Publicly observable state --- val scannedDevices: StateFlow> = scanner.scannedDevices val isScanning: StateFlow = scanner.isScanning @@ -101,23 +98,6 @@ class BluetoothFacade @Inject constructor( val savedScaleName: StateFlow = settingsFacade.savedBluetoothScaleName.stateIn(scope, SharingStarted.WhileSubscribed(5000), null) - /** - * One-shot message with resource ID, args, and duration. - * Used for Snackbar feedback from non-UI layers. - */ - data class OneShotMessage(val resId: Int, val args: List = emptyList(), val duration: SnackbarDuration = SnackbarDuration.Short) - - /** - * One-shot message with plain text. - */ - data class OneShotText(val text: String, val duration: SnackbarDuration = SnackbarDuration.Short) - - private val _oneShotMessages = MutableSharedFlow() - val oneShotMessages: SharedFlow = _oneShotMessages.asSharedFlow() - - private val _oneShotText = MutableSharedFlow() - val oneShotText: SharedFlow = _oneShotText.asSharedFlow() - // --- Current user context --- private val currentAppUser = MutableStateFlow(null) private val currentBtScaleUser = MutableStateFlow(null) @@ -160,7 +140,7 @@ class BluetoothFacade @Inject constructor( fun connectTo(device: ScannedDeviceInfo) { val (supported, handlerName) = scaleFactory.getSupportingHandlerInfo(device) if (!supported) { - scope.launch { _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_saved_scale_no_longer_supported, listOf(device.name ?: "?"))) } + LogManager.w(TAG, "Device ${device.name} is not supported by this app") return } device.isSupported = true @@ -173,7 +153,6 @@ class BluetoothFacade @Inject constructor( val address = savedScaleAddress.value val name = savedScaleName.value if (address == null || name == null) { - _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_no_scale_saved)) return@launch } val already = (connectionStatus.value == ConnectionStatus.CONNECTED || connectionStatus.value == ConnectionStatus.CONNECTING) && @@ -191,20 +170,17 @@ class BluetoothFacade @Inject constructor( fun saveAsPreferred(device: ScannedDeviceInfo) { scope.launch { - val display = device.name ?: application.getString(R.string.unknown_scale_name) + val display = device.name ?: application.getString(R.string.unknown_device) settingsFacade.saveBluetoothScale(device.address, display) - _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_scale_saved_as_preferred, listOf(display))) } } fun provideUserInteractionFeedback(type: BluetoothEvent.UserInteractionType, feedbackData: Any) { val user = currentAppUser.value ?: run { - scope.launch { _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_error_no_app_user_selected)) } connection.clearUserInteractionEvent() return } connection.provideUserInteractionFeedback(type, user.id, feedbackData, Handler(Looper.getMainLooper())) - scope.launch { _oneShotMessages.emit(OneShotMessage(R.string.bt_snackbar_user_input_processed)) } connection.clearUserInteractionEvent() } diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index d2150743..b24278fb 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -19,7 +19,7 @@ package com.health.openscale.core.service import android.annotation.SuppressLint import android.os.Handler -import androidx.compose.material3.SnackbarDuration +import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent import com.health.openscale.core.bluetooth.ScaleCommunicator import com.health.openscale.core.bluetooth.ScaleFactory @@ -31,12 +31,16 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.shared.SnackbarEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -64,8 +68,6 @@ class BleConnector( private val scaleFactory: ScaleFactory, private val databaseRepository: DatabaseRepository, private val getCurrentScaleUser: () -> ScaleUser?, - private val onSavePreferredDevice: suspend (address: String, name: String) -> Unit, - private val onSnackbarText: (message: String, duration: SnackbarDuration) -> Unit, ) : AutoCloseable { private companion object { @@ -73,6 +75,9 @@ class BleConnector( const val DISCONNECT_TIMEOUT_MS = 3000L // Timeout for forceful disconnect if no event received. } + private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val snackbarEvents: SharedFlow = _snackbarEvents.asSharedFlow() + private val _connectedDeviceName = MutableStateFlow(null) /** Emits the name of the currently connected device, or null if not connected. */ val connectedDeviceName: StateFlow = _connectedDeviceName.asStateFlow() @@ -180,8 +185,7 @@ class BleConnector( // but confirm here for safety. _connectedDeviceAddress.value = connectedDeviceInfo.address _connectedDeviceName.value = connectedDeviceInfo.name - onSavePreferredDevice(connectedDeviceInfo.address, connectedDeviceInfo.name ?: "Unknown Scale") - onSnackbarText("Connected to $deviceDisplayName", SnackbarDuration.Short) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_connected_to, messageFormatArgs = listOf(deviceDisplayName))) _connectionError.value = null // Clear any errors on successful connection. LogManager.i(TAG, "Successfully connected to $deviceDisplayName via adapter's isConnected flow.") disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed. @@ -232,8 +236,7 @@ class BleConnector( _connectionStatus.value = ConnectionStatus.CONNECTED _connectedDeviceAddress.value = event.deviceAddress _connectedDeviceName.value = event.deviceName ?: deviceInfo.name // Prefer event name. - onSavePreferredDevice(event.deviceAddress, event.deviceName ?: deviceInfo.name ?: "Unknown Scale") - onSnackbarText("Connected to ${event.deviceName ?: deviceDisplayName}", SnackbarDuration.Short) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_connected_to, messageFormatArgs = listOf(event.deviceName ?: deviceDisplayName))) _connectionError.value = null } } @@ -271,7 +274,7 @@ class BleConnector( } is BluetoothEvent.DeviceMessage -> { LogManager.d(TAG, "Event: Message from $deviceDisplayName: ${event.message}") - onSnackbarText("$deviceDisplayName: ${event.message}", SnackbarDuration.Long) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_device_message, messageFormatArgs = listOf(deviceDisplayName, event.message))) } is BluetoothEvent.Error -> { LogManager.e(TAG, "Event: Error from $deviceDisplayName: ${event.error}") @@ -322,7 +325,7 @@ class BleConnector( val currentAppUserId = getCurrentScaleUser()?.id if (currentAppUserId == 0) { LogManager.e(TAG, "($deviceName): No App User ID to save measurement.") - onSnackbarText("Measurement from $deviceName cannot be assigned to a user.", SnackbarDuration.Long) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_user_missing, messageFormatArgs = listOf(deviceName))) return } LogManager.i(TAG, "($deviceName): Saving measurement for App User ID $currentAppUserId.") @@ -340,7 +343,7 @@ class BleConnector( databaseRepository.getAllMeasurementTypes().firstOrNull() ?.associate { it.key to it.id } ?: run { LogManager.e(TAG, "Could not load MeasurementTypes from DB for $deviceName.") - onSnackbarText("Error: Measurement types not loaded.", SnackbarDuration.Long) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_types_not_loaded)) return@launch } fun getTypeIdFromMap(key: MeasurementTypeKey): Int? = typeKeyToIdMap[key] @@ -416,7 +419,7 @@ class BleConnector( if (values.isEmpty()) { LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") - onSnackbarText("No valid measurement values received from $deviceName.", SnackbarDuration.Long) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_no_values, messageFormatArgs = listOf(deviceName))) return@launch } @@ -426,10 +429,10 @@ class BleConnector( finalValues.forEach { databaseRepository.insertMeasurementValue(it) } LogManager.i(TAG, "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${finalValues.size}") - onSnackbarText("Measurement (${measurementData.weight} kg) from $deviceName saved.", SnackbarDuration.Short) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_saved, messageFormatArgs = listOf(measurementData.weight, deviceName))) } catch (e: Exception) { LogManager.e(TAG, "Error saving measurement from $deviceName.", e) - onSnackbarText("Error saving measurement from $deviceName.", SnackbarDuration.Long) + _snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_save_error, messageFormatArgs = listOf(deviceName))) } } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index 326d6a3a..66fbcd03 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -18,6 +18,7 @@ package com.health.openscale.ui.navigation import android.util.Pair +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -185,6 +186,10 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { else -> "" // Default to empty string if title data is null or unexpected type } + BackHandler(enabled = drawerState.isOpen) { + scope.launch { drawerState.close() } + } + // Reset top bar actions when the current route changes. // This prevents actions from a previous screen from lingering on the new screen. LaunchedEffect(currentRoute) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index fa0d063e..0ed88534 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -341,7 +341,7 @@ fun OverviewScreen( } } else { sharedViewModel.showSnackbar( - message = context.getString(R.string.bt_snackbar_bluetooth_disabled_to_connect_default), + message = context.getString(R.string.bluetooth_permissions_required_for_scan), duration = SnackbarDuration.Long ) } @@ -360,7 +360,7 @@ fun OverviewScreen( } } else { sharedViewModel.showSnackbar( - messageResId = R.string.bt_snackbar_permissions_required_to_connect_default + messageResId = R.string.bluetooth_permissions_required_for_scan ) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt index c7d6e565..f160522c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt @@ -63,30 +63,13 @@ class BluetoothViewModel @Inject constructor( val savedScaleName = bt.savedScaleName // --- Snackbar events for UI --- - private val _snackbarEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val snackbarEvents: SharedFlow = _snackbarEvents.asSharedFlow() init { - // Translate facade one-shot messages into SnackbarEvents viewModelScope.launch { - bt.oneShotMessages.collect { msg -> - _snackbarEvents.emit( - SnackbarEvent( - messageResId = msg.resId, - messageFormatArgs = msg.args, - duration = msg.duration - ) - ) - } - } - viewModelScope.launch { - bt.oneShotText.collect { txt -> - _snackbarEvents.emit( - SnackbarEvent( - message = txt.text, - duration = txt.duration - ) - ) + bt.snackbarEventsFromConnector.collect { evt -> + _snackbarEvents.emit(evt) } } } @@ -94,7 +77,7 @@ class BluetoothViewModel @Inject constructor( // --- Delegated actions --- fun requestStartDeviceScan() { if (!bt.isBluetoothEnabled()) { - emitSnack(R.string.bt_snackbar_bluetooth_disabled_to_scan, SnackbarDuration.Long) + emitSnack(R.string.bluetooth_must_be_enabled_for_scan, SnackbarDuration.Long) return } bt.startScan(SCAN_DURATION_MS) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt index 56471fa8..0226d7bb 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -67,7 +67,7 @@ class SettingsViewModel @Inject constructor( } // --- Snackbar --- - private val _snackbarEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val snackbarEvents = _snackbarEvents.asSharedFlow() private suspend fun showSnackbar( diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index 67ff59df..239b1bbe 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -93,7 +93,7 @@ class SharedViewModel @Inject constructor( fun setTopBarActions(actions: List) { _topBarActions.value = actions } // --- Snackbar events --- - private val _snackbarEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val snackbarEvents: SharedFlow = _snackbarEvents.asSharedFlow() fun showSnackbar( diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 8778f5a6..465d5e44 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -217,6 +217,18 @@ Nicht unterstützt %1$d dBm + + Verbunden mit %1$s + Verbindung zu %1$s fehlgeschlagen: %2$s + %1$s wird nicht unterstützt. + Treiber für %1$s nicht gefunden oder interner Fehler. + Messung von %1$s kann keinem Benutzer zugeordnet werden. + Fehler: Messtypen konnten nicht geladen werden. + Keine gültigen Messwerte von %1$s empfangen. + Messung (%1$.1f kg) von %2$s gespeichert. + Fehler beim Speichern der Messung von %1$s. + %1$s: %2$s + Symbol für erforderliche Berechtigungen Berechtigungen erforderlich @@ -448,27 +460,6 @@ Keine Bewertung möglich: Kein passender Altersbereich zum Messzeitpunkt. Auffälliger Wert: außerhalb des plausiblen Bereichs (%1$.0f–%2$.0f%%). - - Bluetooth-Berechtigungen werden zum Scannen von Geräten benötigt. - Bluetooth ist deaktiviert. Bitte aktiviere es, um nach Geräten zu scannen. - - Bluetooth-Berechtigungen werden benötigt, um eine Verbindung zu %1$s herzustellen. - Bluetooth-Berechtigungen werden benötigt, um eine Verbindung zum Gerät herzustellen. - Bluetooth ist deaktiviert. Bitte aktiviere es, um eine Verbindung zu %1$s herzustellen. - Bluetooth ist deaktiviert. Bitte aktiviere es, um eine Verbindung zum Gerät herzustellen. - - %1$s als bevorzugte Waage gespeichert. - Gespeicherte Waage \'%1$s\' wird nicht mehr unterstützt. - Keine Bluetooth-Waage in den Einstellungen gespeichert. - - Fehler: Kein App-Benutzer ausgewählt. - Benutzereingabe verarbeitet. - - Vorgang erfolgreich. - Vorgang fehlgeschlagen. Bitte versuche es erneut. - das Gerät - Unbekannte Waage - Diese Waage wurde nicht gekoppelt!\n\nHalten Sie die Taste an der Unterseite der Waage gedrückt, um sie in den Kopplungsmodus zu versetzen, und verbinden Sie sich dann erneut, um das Gerätepasswort abzurufen. Kopplung erfolgreich!\n\nVerbinden Sie sich erneut, um Messdaten abzurufen. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 5f6f6892..07e7a8f4 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -219,6 +219,18 @@ Not Supported %1$d dBm + + Connected to %1$s + Connection to %1$s failed: %2$s + %1$s is not supported. + Driver for %1$s not found or internal error. + Measurement from %1$s cannot be assigned to a user. + Error: Measurement types not loaded. + No valid measurement values received from %1$s. + Measurement (%1$.1f kg) from %2$s saved. + Error saving measurement from %1$s. + %1$s: %2$s + Permissions required icon Permissions Required @@ -450,27 +462,6 @@ No evaluation possible: No matching age band at the time of measurement. Unusual value: outside the plausible range (%1$.0f–%2$.0f%%). - - Bluetooth permissions are required to scan for devices. - Bluetooth is disabled. Please enable it to scan for devices. - - Bluetooth permissions are required to connect to %1$s. - Bluetooth permissions are required to connect to the device. - Bluetooth is disabled. Please enable it to connect to %1$s. - Bluetooth is disabled. Please enable it to connect to the device. - - %1$s saved as preferred scale. - Saved scale \'%1$s\' is no longer supported. - No Bluetooth scale saved in settings. - - Error: No app user selected. - User input processed. - - Operation successful. - Operation failed. Please try again. - the device - Unknown Scale - This scale has not been paired!\n\nHold the button on the bottom of the scale to switch it to pairing mode, and then reconnect to retrieve the device password. Pairing succeeded!\n\nReconnect to retrieve measurement data.