1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-29 11:10:35 +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:
oliexdev
2025-08-26 18:18:30 +02:00
parent 5e755382e1
commit 769a3db921
6 changed files with 80 additions and 11 deletions

View File

@@ -42,8 +42,10 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
/**
* 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 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.
* This function is suspendable and performs operations in the [scope] provided during construction.
@@ -185,7 +219,6 @@ class BleConnector(
// but confirm here for safety.
_connectedDeviceAddress.value = connectedDeviceInfo.address
_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.
LogManager.i(TAG, "Successfully connected to $deviceDisplayName via adapter's isConnected flow.")
disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed.
@@ -429,7 +462,9 @@ class BleConnector(
finalValues.forEach { databaseRepository.insertMeasurementValue(it) }
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) {
LogManager.e(TAG, "Error saving measurement from $deviceName.", e)
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_save_error, messageFormatArgs = listOf(deviceName)))

View File

@@ -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.Blue
import com.health.openscale.ui.theme.White
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
@@ -202,17 +204,25 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
sharedViewModel.snackbarEvents,
settingsViewModel.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(
requireNotNull(evt.messageResId),
*evt.messageFormatArgs.toTypedArray()
)
val action = evt.actionLabel ?: evt.actionLabelResId?.let { context.getString(it) }
val res = snackbarHostState.showSnackbar(
message = msg,
actionLabel = action,
duration = evt.duration
)
val res = snackbarHostState.run {
currentSnackbarData?.dismiss()
showSnackbar(
message = msg,
actionLabel = action,
duration = evt.duration
)
}
if (res == SnackbarResult.ActionPerformed) evt.onAction?.invoke()
}
}

View File

@@ -45,6 +45,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.shared.TopBarAction
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 java.text.DateFormat
import java.text.SimpleDateFormat
@@ -471,6 +476,14 @@ fun OverviewScreen(
)
}
} else {
val topId = items.firstOrNull()?.measurementWithValues?.measurement?.id
LaunchedEffect(topId) {
if (topId != null && !listState.isScrollInProgress) {
delay(60)
listState.smartScrollTo(0)
}
}
// Chart
Box(modifier = Modifier.fillMaxWidth()) {
LineChart(
@@ -488,9 +501,8 @@ fun OverviewScreen(
?.let { (targetIndex, mwv) ->
val targetId = mwv.measurement.id
scope.launch {
listState.animateScrollToItem(
index = targetIndex,
scrollOffset = 0
listState.smartScrollTo(
index = targetIndex
)
highlightedMeasurementId = targetId
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.
* It prompts the user to add or select a user.

View File

@@ -105,6 +105,8 @@ class SharedViewModel @Inject constructor(
actionLabel: String? = null,
onAction: (() -> Unit)? = null
) {
if (message == null && messageResId == null) return
viewModelScope.launch {
_snackbarEvents.emit(
SnackbarEvent(

View File

@@ -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_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="saved_measurements_message">%1$d Messungen gespeichert</string>
</resources>

View File

@@ -508,4 +508,7 @@
<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="saved_measurements_message">Saved %1$d measurements</string>
</resources>