mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-29 19:20:36 +02:00
This commit introduces a new feature that groups snackbar messages when multiple measurements are saved in quick succession. Instead of displaying individual "Measurement saved" messages for each, a single message like "Saved X measurements" will now be shown.
Additionally, this commit: - Improves the snackbar display logic to prevent duplicate messages and introduces a debounce mechanism. - Enhances the Overview screen's scrolling behavior to smoothly scroll to the top when new items are added, and also improves the scroll behavior when tapping on chart points.
This commit is contained in:
@@ -42,8 +42,10 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages Bluetooth connections to scale devices, handling the connection lifecycle,
|
* Manages Bluetooth connections to scale devices, handling the connection lifecycle,
|
||||||
@@ -102,6 +104,38 @@ class BleConnector(
|
|||||||
private var communicatorJob: Job? = null // Job for observing events from the activeCommunicator.
|
private var communicatorJob: Job? = null // Job for observing events from the activeCommunicator.
|
||||||
private var disconnectTimeoutJob: Job? = null // Job for handling disconnect timeouts.
|
private var disconnectTimeoutJob: Job? = null // Job for handling disconnect timeouts.
|
||||||
|
|
||||||
|
private val savedBurstSignal = MutableSharedFlow<Unit>(replay = 0, extraBufferCapacity = 64)
|
||||||
|
private val pendingSavedCount = AtomicInteger(0)
|
||||||
|
@Volatile private var lastSavedArgs: List<Any> = emptyList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
savedBurstSignal
|
||||||
|
.debounce(700)
|
||||||
|
.collect {
|
||||||
|
val count = pendingSavedCount.getAndSet(0)
|
||||||
|
if (count <= 0) return@collect
|
||||||
|
|
||||||
|
if (count == 1) {
|
||||||
|
_snackbarEvents.emit(
|
||||||
|
SnackbarEvent(
|
||||||
|
messageResId = R.string.bluetooth_connector_measurement_saved,
|
||||||
|
messageFormatArgs = lastSavedArgs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_snackbarEvents.tryEmit(
|
||||||
|
SnackbarEvent(
|
||||||
|
messageResId = R.string.saved_measurements_message,
|
||||||
|
messageFormatArgs = listOf(count)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to connect to the specified Bluetooth device.
|
* Attempts to connect to the specified Bluetooth device.
|
||||||
* This function is suspendable and performs operations in the [scope] provided during construction.
|
* This function is suspendable and performs operations in the [scope] provided during construction.
|
||||||
@@ -185,7 +219,6 @@ class BleConnector(
|
|||||||
// but confirm here for safety.
|
// but confirm here for safety.
|
||||||
_connectedDeviceAddress.value = connectedDeviceInfo.address
|
_connectedDeviceAddress.value = connectedDeviceInfo.address
|
||||||
_connectedDeviceName.value = connectedDeviceInfo.name
|
_connectedDeviceName.value = connectedDeviceInfo.name
|
||||||
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_connected_to, messageFormatArgs = listOf(deviceDisplayName)))
|
|
||||||
_connectionError.value = null // Clear any errors on successful connection.
|
_connectionError.value = null // Clear any errors on successful connection.
|
||||||
LogManager.i(TAG, "Successfully connected to $deviceDisplayName via adapter's isConnected flow.")
|
LogManager.i(TAG, "Successfully connected to $deviceDisplayName via adapter's isConnected flow.")
|
||||||
disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed.
|
disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed.
|
||||||
@@ -429,7 +462,9 @@ class BleConnector(
|
|||||||
finalValues.forEach { databaseRepository.insertMeasurementValue(it) }
|
finalValues.forEach { databaseRepository.insertMeasurementValue(it) }
|
||||||
|
|
||||||
LogManager.i(TAG, "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${finalValues.size}")
|
LogManager.i(TAG, "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${finalValues.size}")
|
||||||
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_saved, messageFormatArgs = listOf(measurementData.weight, deviceName)))
|
pendingSavedCount.incrementAndGet()
|
||||||
|
lastSavedArgs = listOf(measurementData.weight, deviceName)
|
||||||
|
savedBurstSignal.tryEmit(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogManager.e(TAG, "Error saving measurement from $deviceName.", e)
|
LogManager.e(TAG, "Error saving measurement from $deviceName.", e)
|
||||||
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_save_error, messageFormatArgs = listOf(deviceName)))
|
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_save_error, messageFormatArgs = listOf(deviceName)))
|
||||||
|
@@ -130,6 +130,8 @@ import com.health.openscale.ui.screen.statistics.StatisticsScreen
|
|||||||
import com.health.openscale.ui.theme.Black
|
import com.health.openscale.ui.theme.Black
|
||||||
import com.health.openscale.ui.theme.Blue
|
import com.health.openscale.ui.theme.Blue
|
||||||
import com.health.openscale.ui.theme.White
|
import com.health.openscale.ui.theme.White
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,17 +204,25 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
|
|||||||
sharedViewModel.snackbarEvents,
|
sharedViewModel.snackbarEvents,
|
||||||
settingsViewModel.snackbarEvents,
|
settingsViewModel.snackbarEvents,
|
||||||
bluetoothViewModel.snackbarEvents
|
bluetoothViewModel.snackbarEvents
|
||||||
).collect { evt ->
|
)
|
||||||
|
.distinctUntilChanged { a, b ->
|
||||||
|
a.messageResId == b.messageResId && a.message == b.message
|
||||||
|
}
|
||||||
|
.debounce(150)
|
||||||
|
.collect { evt ->
|
||||||
val msg = evt.message ?: context.getString(
|
val msg = evt.message ?: context.getString(
|
||||||
requireNotNull(evt.messageResId),
|
requireNotNull(evt.messageResId),
|
||||||
*evt.messageFormatArgs.toTypedArray()
|
*evt.messageFormatArgs.toTypedArray()
|
||||||
)
|
)
|
||||||
val action = evt.actionLabel ?: evt.actionLabelResId?.let { context.getString(it) }
|
val action = evt.actionLabel ?: evt.actionLabelResId?.let { context.getString(it) }
|
||||||
val res = snackbarHostState.showSnackbar(
|
val res = snackbarHostState.run {
|
||||||
message = msg,
|
currentSnackbarData?.dismiss()
|
||||||
actionLabel = action,
|
showSnackbar(
|
||||||
duration = evt.duration
|
message = msg,
|
||||||
)
|
actionLabel = action,
|
||||||
|
duration = evt.duration
|
||||||
|
)
|
||||||
|
}
|
||||||
if (res == SnackbarResult.ActionPerformed) evt.onAction?.invoke()
|
if (res == SnackbarResult.ActionPerformed) evt.onAction?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -45,6 +45,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -120,6 +121,10 @@ import com.health.openscale.ui.screen.components.LineChart
|
|||||||
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||||
import com.health.openscale.ui.shared.TopBarAction
|
import com.health.openscale.ui.shared.TopBarAction
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -471,6 +476,14 @@ fun OverviewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val topId = items.firstOrNull()?.measurementWithValues?.measurement?.id
|
||||||
|
LaunchedEffect(topId) {
|
||||||
|
if (topId != null && !listState.isScrollInProgress) {
|
||||||
|
delay(60)
|
||||||
|
listState.smartScrollTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chart
|
// Chart
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
LineChart(
|
LineChart(
|
||||||
@@ -488,9 +501,8 @@ fun OverviewScreen(
|
|||||||
?.let { (targetIndex, mwv) ->
|
?.let { (targetIndex, mwv) ->
|
||||||
val targetId = mwv.measurement.id
|
val targetId = mwv.measurement.id
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState.animateScrollToItem(
|
listState.smartScrollTo(
|
||||||
index = targetIndex,
|
index = targetIndex
|
||||||
scrollOffset = 0
|
|
||||||
)
|
)
|
||||||
highlightedMeasurementId = targetId
|
highlightedMeasurementId = targetId
|
||||||
delay(600)
|
delay(600)
|
||||||
@@ -542,6 +554,11 @@ fun OverviewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun LazyListState.smartScrollTo(index: Int) {
|
||||||
|
val dist = kotlin.math.abs(firstVisibleItemIndex - index)
|
||||||
|
if (dist > 20) scrollToItem(index) else animateScrollToItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Composable card displayed when no user is currently selected/active.
|
* A Composable card displayed when no user is currently selected/active.
|
||||||
* It prompts the user to add or select a user.
|
* It prompts the user to add or select a user.
|
||||||
|
@@ -105,6 +105,8 @@ class SharedViewModel @Inject constructor(
|
|||||||
actionLabel: String? = null,
|
actionLabel: String? = null,
|
||||||
onAction: (() -> Unit)? = null
|
onAction: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
|
if (message == null && messageResId == null) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_snackbarEvents.emit(
|
_snackbarEvents.emit(
|
||||||
SnackbarEvent(
|
SnackbarEvent(
|
||||||
|
@@ -508,4 +508,6 @@
|
|||||||
<string name="dialog_bt_error_loading_user_list_format">Fehler: Benutzerdaten konnten nicht korrekt geladen werden.</string>
|
<string name="dialog_bt_error_loading_user_list_format">Fehler: Benutzerdaten konnten nicht korrekt geladen werden.</string>
|
||||||
<string name="dialog_bt_select_user_prompt">Bitte einen Benutzer auswählen.</string>
|
<string name="dialog_bt_select_user_prompt">Bitte einen Benutzer auswählen.</string>
|
||||||
<string name="dialog_bt_enter_valid_code_prompt">Bitte einen gültigen Code eingeben.</string>
|
<string name="dialog_bt_enter_valid_code_prompt">Bitte einen gültigen Code eingeben.</string>
|
||||||
|
<string name="saved_measurements_message">%1$d Messungen gespeichert</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
@@ -508,4 +508,7 @@
|
|||||||
<string name="dialog_bt_select_user_prompt">Please select a user.</string>
|
<string name="dialog_bt_select_user_prompt">Please select a user.</string>
|
||||||
<string name="dialog_bt_enter_valid_code_prompt">Please enter a valid code.</string>
|
<string name="dialog_bt_enter_valid_code_prompt">Please enter a valid code.</string>
|
||||||
|
|
||||||
|
<string name="saved_measurements_message">Saved %1$d measurements</string>
|
||||||
|
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
Reference in New Issue
Block a user