1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-10-27 14:01:24 +01:00

This commit introduces the ability to select multiple measurement entries in the table view. Users can now perform batch operations like deleting, exporting, or changing the assigned user for the selected items.

This commit is contained in:
oliexdev
2025-09-23 20:43:38 +02:00
parent ef75860ab1
commit afbe5f9103
9 changed files with 457 additions and 33 deletions

View File

@@ -90,8 +90,9 @@ class DataManagementFacade @Inject constructor(
suspend fun exportUserToCsv(
userId: Int,
uri: Uri,
resolver: ContentResolver
): Result<Int> = importExport.exportUserToCsv(userId, uri, resolver)
resolver: ContentResolver,
filterByMeasurementIds: List<Int>? = null
): Result<Int> = importExport.exportUserToCsv(userId, uri, resolver, filterByMeasurementIds)
suspend fun importUserFromCsv(
userId: Int,

View File

@@ -91,7 +91,8 @@ class ImportExportUseCases @Inject constructor(
suspend fun exportUserToCsv(
userId: Int,
uri: Uri,
contentResolver: ContentResolver
contentResolver: ContentResolver,
filterByMeasurementIds: List<Int>? = null
): Result<Int> = runCatching {
LogManager.i(TAG, "CSV export for userId=$userId -> $uri")
@@ -120,15 +121,21 @@ class ImportExportUseCases @Inject constructor(
addAll(valueColumnKeys.sorted())
}
val userMeasurementsWithValues: List<MeasurementWithValues> =
val allUserMeasurementsWithValues: List<MeasurementWithValues> =
repository.getMeasurementsWithValuesForUser(userId).first()
require(userMeasurementsWithValues.isNotEmpty()) {
val filteredUserMeasurementsWithValues = if (filterByMeasurementIds != null) {
allUserMeasurementsWithValues.filter { it.measurement.id in filterByMeasurementIds }
} else {
allUserMeasurementsWithValues
}
require(filteredUserMeasurementsWithValues.isNotEmpty()) {
"No measurements found for userId=$userId"
}
val rows = mutableListOf<Map<String, String?>>()
userMeasurementsWithValues.forEach { mwv ->
filteredUserMeasurementsWithValues.forEach { mwv ->
val zdt = Instant.ofEpochMilli(mwv.measurement.timestamp).atZone(ZoneId.systemDefault())
val row = mutableMapOf<String, String?>(
dateColumnKey to dateFormatter.format(zdt),

View File

@@ -179,6 +179,7 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
// Collect UI states from SharedViewModel
val topBarTitleFromVM by sharedViewModel.topBarTitle.collectAsState()
val topBarActions by sharedViewModel.topBarActions.collectAsState()
val isInContextualSelectionMode by sharedViewModel.isInContextualSelectionMode.collectAsState()
val allUsers by sharedViewModel.allUsers.collectAsState()
val selectedUser by sharedViewModel.selectedUser.collectAsState()
@@ -536,7 +537,7 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
}
// Show user switcher dropdown if on a main route and users exist.
if (currentRoute in mainRoutes && allUsers.isNotEmpty() && currentRoute != Routes.SETTINGS) {
if (!isInContextualSelectionMode && currentRoute in mainRoutes && allUsers.isNotEmpty() && currentRoute != Routes.SETTINGS) {
UserDropdownAsAction(
users = allUsers,
selectedUser = selectedUser,

View File

@@ -49,6 +49,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -77,6 +78,7 @@ import com.health.openscale.ui.screen.dialog.TextInputDialog
import com.health.openscale.ui.screen.dialog.TimeInputDialog
import com.health.openscale.ui.screen.dialog.UserInputDialog
import com.health.openscale.ui.shared.TopBarAction
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
@@ -100,6 +102,7 @@ fun MeasurementDetailScreen(
sharedViewModel: SharedViewModel
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Holds the string representation of measurement values, keyed by MeasurementType ID.
val valuesState = remember { mutableStateMapOf<Int, String>() }
@@ -311,7 +314,9 @@ fun MeasurementDetailScreen(
}
if (allConversionsOk) {
sharedViewModel.saveMeasurement(measurementToSave, valueList)
scope.launch {
sharedViewModel.saveMeasurement(measurementToSave, valueList)
}
pendingUserId = null
isPendingNavigation = true // Trigger loading indicator and navigate back.
navController.popBackStack()

View File

@@ -757,7 +757,9 @@ fun OverviewScreen(
)
},
onDelete = {
sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement)
scope.launch {
sharedViewModel.deleteMeasurement(enrichedItem.measurementWithValues.measurement)
}
},
isHighlighted = (highlightedMeasurementId == enrichedItem.measurementWithValues.measurement.id)
)

View File

@@ -14,6 +14,10 @@
*/
package com.health.openscale.ui.screen.table
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -37,22 +41,35 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.SupervisorAccount
import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -61,6 +78,7 @@ import androidx.navigation.NavController
import com.health.openscale.R
import com.health.openscale.core.data.EvaluationState
import com.health.openscale.core.data.InputFieldType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.Trend
import com.health.openscale.core.data.UnitType
@@ -68,8 +86,12 @@ import com.health.openscale.ui.navigation.Routes
import com.health.openscale.ui.screen.components.MeasurementTypeFilterRow
import com.health.openscale.ui.shared.SharedViewModel
import com.health.openscale.core.utils.LocaleUtils
import com.health.openscale.ui.screen.dialog.UserInputDialog
import com.health.openscale.ui.shared.TopBarAction
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -125,6 +147,7 @@ fun TableScreen(
navController: NavController,
sharedViewModel: SharedViewModel
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState()
val allAvailableTypesFromVM by sharedViewModel.measurementTypes.collectAsState()
@@ -132,12 +155,35 @@ fun TableScreen(
// Column selection state provided by filter row.
val selectedColumnIdsFromFilter = remember { mutableStateListOf<Int>() }
var isInSelectionMode by rememberSaveable { mutableStateOf(false) }
val selectedItemIds = remember { mutableStateListOf<Int>() }
val allUsersForDialog by sharedViewModel.allUsers.collectAsState()
var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) }
var showChangeUserDialog by rememberSaveable { mutableStateOf(false) }
val displayedTypes =
remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) {
allAvailableTypesFromVM.filter { it.id in selectedColumnIdsFromFilter }
}
val exportCsvLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/csv"),
onResult = { uri: Uri? ->
val currentUserId = sharedViewModel.selectedUserId.value
if (uri != null && selectedItemIds.isNotEmpty() && currentUserId != null && currentUserId != 0) {
sharedViewModel.performCsvExport(
userId = currentUserId,
uri = uri,
contentResolver = context.contentResolver,
filterByMeasurementIds = selectedItemIds.toList()
)
isInSelectionMode = false
selectedItemIds.clear()
}
}
)
// Transform measurements -> table rows (compute eval state & formatted strings here).
val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM, userEvaluationContext) {
if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) {
@@ -253,8 +299,233 @@ fun TableScreen(
val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection)
val dateColumnHeader = stringResource(id = R.string.table_header_date)
LaunchedEffect(Unit, tableScreenTitle) {
sharedViewModel.setTopBarTitle(tableScreenTitle)
fun deleteSelectedItems(selectedItemIds : List<Int>) {
if (selectedItemIds.isEmpty()) {
return
}
scope.launch {
var allSucceeded = true
for (id in selectedItemIds) {
val measurementWithValues = sharedViewModel.getMeasurementById(id).firstOrNull()
if (measurementWithValues != null) {
val success = sharedViewModel.deleteMeasurement(measurementWithValues.measurement, true)
if (!success) {
allSucceeded = false
break
}
}
}
if (allSucceeded) {
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_items_deleted_successfully, formatArgs = listOf(selectedItemIds.size))
} else {
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_error_deleting_items)
}
}
}
fun exportSelectedItems(selectedItemIds: List<Int>) {
if (selectedItemIds.isEmpty()) {
return
}
val timestamp = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
val fileName = "${timestamp}_openscale_selected_export.csv"
exportCsvLauncher.launch(fileName)
}
fun changeUserOfSelectedItems(selectedItemIds : List<Int>, newUserId : Int) {
if (selectedItemIds.isEmpty()) {
return
}
scope.launch {
var allSucceeded = true
for (id in selectedItemIds) {
val measurementWithValues = sharedViewModel.getMeasurementById(id).firstOrNull()
if (measurementWithValues != null) {
val originalMeasurement = measurementWithValues.measurement
val originalValues = measurementWithValues.values.map { it.value }
val updatedMeasurement = originalMeasurement.copy(userId = newUserId)
val success = sharedViewModel.saveMeasurement(updatedMeasurement, originalValues, true)
if (!success) {
allSucceeded = false
break
}
}
}
if (allSucceeded) {
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_items_user_changed_successfully, formatArgs = listOf(selectedItemIds.size))
} else {
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_error_user_changed_items)
}
}
}
if (showChangeUserDialog) {
val usersForDialog = allUsersForDialog.filter { user ->
user.id != 0 && user.id != sharedViewModel.selectedUserId.value
}
if (usersForDialog.isNotEmpty()) {
UserInputDialog(
title = stringResource(R.string.dialog_title_select_user_for_assignment),
users = usersForDialog,
initialSelectedId = usersForDialog.firstOrNull()?.id,
measurementIcon = MeasurementTypeIcon.IC_USER,
iconBackgroundColor = MaterialTheme.colorScheme.primary,
onDismiss = {
showChangeUserDialog = false
},
onConfirm = { selectedNewUserId ->
if (selectedNewUserId != null) {
changeUserOfSelectedItems(selectedItemIds.toList(), selectedNewUserId)
}
showChangeUserDialog = false
isInSelectionMode = false
selectedItemIds.clear()
}
)
} else {
LaunchedEffect(Unit) {
showChangeUserDialog = false
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_no_other_users_to_change_to)
}
}
}
if (showDeleteConfirmDialog) {
AlertDialog(
onDismissRequest = {
showDeleteConfirmDialog = false
},
title = {
Text(text = stringResource(id = R.string.dialog_title_delete_items))
},
text = {
val messageResId = if (selectedItemIds.size == 1) {
R.string.dialog_message_delete_item
} else {
R.string.dialog_message_delete_items
}
Text(text = stringResource(id = messageResId, selectedItemIds.size))
},
confirmButton = {
TextButton(
onClick = {
deleteSelectedItems(selectedItemIds.toList())
showDeleteConfirmDialog = false
isInSelectionMode = false
selectedItemIds.clear()
}
) {
Text(stringResource(id = R.string.delete_button_label).uppercase(Locale.getDefault()))
}
},
dismissButton = {
TextButton(
onClick = {
showDeleteConfirmDialog = false
}
) {
Text(stringResource(id = R.string.cancel_button).uppercase(Locale.getDefault()))
}
},
icon = {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
}
)
}
LaunchedEffect(Unit, tableScreenTitle, isInSelectionMode, selectedItemIds.toList(), enrichedMeasurements) {
sharedViewModel.setContextualSelectionMode(isInSelectionMode)
if (isInSelectionMode) {
sharedViewModel.setTopBarTitle(context.getString(R.string.items_selected_count, selectedItemIds.size))
val actions = mutableListOf<TopBarAction>()
actions.add(
TopBarAction(
icon = Icons.Filled.SupervisorAccount,
contentDescriptionResId = R.string.desc_change_user,
onClick = {
val usersSelectable = allUsersForDialog.filter { it.id != 0 && it.id != sharedViewModel.selectedUser.value?.id }
if (usersSelectable.isNotEmpty()) {
showChangeUserDialog = true
} else {
sharedViewModel.showSnackbar(messageResId = R.string.snackbar_no_other_users_to_change_to)
}
}
)
)
actions.add(
TopBarAction(
icon = Icons.Filled.FileDownload,
contentDescriptionResId = R.string.desc_export_selected,
onClick = {
exportSelectedItems(selectedItemIds)
}
)
)
actions.add(
TopBarAction(
icon = Icons.Filled.Delete,
contentDescriptionResId = R.string.desc_delete_selected,
onClick = {
if (selectedItemIds.isNotEmpty()) {
showDeleteConfirmDialog = true
}
}
)
)
actions.add(
TopBarAction(
icon = Icons.Filled.Close,
contentDescriptionResId = R.string.desc_cancel_selection_mode,
onClick = {
isInSelectionMode = false
selectedItemIds.clear()
}
)
)
sharedViewModel.setTopBarActions(actions)
} else {
sharedViewModel.setTopBarTitle(tableScreenTitle)
val defaultActions = mutableListOf<TopBarAction>()
if (!enrichedMeasurements.isEmpty()) {
defaultActions.add(
TopBarAction(
icon = Icons.Outlined.CheckBox,
contentDescriptionResId = R.string.desc_enter_selection_mode,
onClick = { isInSelectionMode = true }
)
)
}
sharedViewModel.setTopBarActions(defaultActions)
}
}
if (isInSelectionMode) {
BackHandler(enabled = true) {
isInSelectionMode = false
selectedItemIds.clear()
}
}
val horizontalScrollState = rememberScrollState()
@@ -271,7 +542,14 @@ fun TableScreen(
onPersistSelectedTypeIds = { idsToSave ->
scope.launch { sharedViewModel.saveSelectedTableTypeIds(idsToSave) }
},
filterLogic = { allTypes -> allTypes.filter { it.isEnabled } },
filterLogic = { allTypes ->
allTypes.filter {
it.isEnabled &&
it.key != MeasurementTypeKey.DATE &&
it.key != MeasurementTypeKey.TIME &&
it.key != MeasurementTypeKey.USER
}
},
defaultSelectionLogic = { availableFilteredTypes ->
val defaultDesiredTypeIds = listOf(
MeasurementTypeKey.WEIGHT.id,
@@ -337,6 +615,40 @@ fun TableScreen(
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
if (isInSelectionMode) {
val allItemsSelected = tableData.isNotEmpty() && selectedItemIds.size == tableData.size
val noItemsSelected = selectedItemIds.isEmpty()
val checkboxState = when {
allItemsSelected -> ToggleableState.On
noItemsSelected -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
Box(modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 6.dp),
contentAlignment = Alignment.CenterStart
) {
TriStateCheckbox(
state = checkboxState,
onClick = {
when (checkboxState) {
ToggleableState.On -> selectedItemIds.clear()
ToggleableState.Off -> {
selectedItemIds.clear()
selectedItemIds.addAll(tableData.map { it.measurementId })
}
ToggleableState.Indeterminate -> {
selectedItemIds.clear()
selectedItemIds.addAll(tableData.map { it.measurementId })
}
}
}
)
}
}
TableHeaderCellInternal(
text = dateColumnHeader,
modifier = Modifier
@@ -368,27 +680,63 @@ fun TableScreen(
// --- DATA ROWS ---
LazyColumn(Modifier.fillMaxSize()) {
items(tableData, key = { it.measurementId }) { rowData ->
val isSelected = selectedItemIds.contains(rowData.measurementId)
Row(
Modifier
.fillMaxWidth()
.background(
if (isSelected && isInSelectionMode) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
}
)
.clickable {
navController.navigate(
Routes.measurementDetail(
rowData.measurementId,
sharedViewModel.selectedUserId.value
if (isInSelectionMode) {
if (isSelected) {
selectedItemIds.remove(rowData.measurementId)
} else {
selectedItemIds.add(rowData.measurementId)
}
} else {
navController.navigate(
Routes.measurementDetail(
rowData.measurementId,
sharedViewModel.selectedUserId.value
)
)
)
}
}
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
// Date cell (fixed column)
if (isInSelectionMode) {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 6.dp),
contentAlignment = Alignment.CenterStart
) {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
if (checked) {
selectedItemIds.add(rowData.measurementId)
} else {
selectedItemIds.remove(rowData.measurementId)
}
}
)
}
}
TableDataCellInternal(
cellData = null,
fixedText = rowData.formattedTimestamp,
modifier = Modifier
.widthIn(min = dateColMin, max = dateColMax)
.background(MaterialTheme.colorScheme.surface)
.fillMaxHeight(),
alignment = TextAlign.Start,
isDateCell = true

View File

@@ -17,6 +17,9 @@
*/
package com.health.openscale.ui.shared
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.ViewModel
@@ -30,6 +33,7 @@ import com.health.openscale.core.data.SmoothingAlgorithm
import com.health.openscale.core.data.TimeRangeFilter
import com.health.openscale.core.data.User
import com.health.openscale.core.data.UserGoals
import com.health.openscale.core.facade.DataManagementFacade
import com.health.openscale.core.facade.MeasurementFacade
import com.health.openscale.core.facade.SettingsFacade
import com.health.openscale.core.facade.UserFacade
@@ -38,6 +42,8 @@ import com.health.openscale.core.model.MeasurementWithValues
import com.health.openscale.core.model.UserEvaluationContext
import com.health.openscale.core.usecase.MeasurementEvaluationResult
import com.health.openscale.core.utils.LogManager
import com.health.openscale.core.utils.LogManager.init
import com.health.openscale.ui.screen.settings.SettingsViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -52,12 +58,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@@ -71,6 +79,7 @@ import javax.inject.Inject
class SharedViewModel @Inject constructor(
private val userFacade: UserFacade,
private val measurementFacade: MeasurementFacade,
private val dataManagementFacade: DataManagementFacade,
private val settingsFacade: SettingsFacade
) : ViewModel(), SettingsFacade by settingsFacade {
companion object {
@@ -94,6 +103,13 @@ class SharedViewModel @Inject constructor(
fun setTopBarAction(action: TopBarAction?) { _topBarActions.value = if (action != null) listOf(action) else emptyList() }
fun setTopBarActions(actions: List<TopBarAction>) { _topBarActions.value = actions }
private val _isInContextualSelectionMode = MutableStateFlow(false)
val isInContextualSelectionMode: StateFlow<Boolean> = _isInContextualSelectionMode.asStateFlow()
fun setContextualSelectionMode(isActive: Boolean) {
_isInContextualSelectionMode.value = isActive
}
// --- Snackbar events ---
private val _snackbarEvents = MutableSharedFlow<SnackbarEvent>(replay = 0, extraBufferCapacity = 1)
val snackbarEvents: SharedFlow<SnackbarEvent> = _snackbarEvents.asSharedFlow()
@@ -280,6 +296,19 @@ class SharedViewModel @Inject constructor(
}
}
fun performCsvExport(userId: Int, uri: Uri, contentResolver: ContentResolver, filterByMeasurementIds: List<Int>? = null) {
viewModelScope.launch {
try {
val rows = dataManagementFacade.exportUserToCsv(userId, uri, contentResolver, filterByMeasurementIds).getOrThrow()
if (rows > 0) showSnackbar(messageResId = R.string.export_successful)
else showSnackbar(messageResId = R.string.export_error_no_exportable_values)
} catch (e: Exception) {
LogManager.e(TAG, "CSV export error", e)
showSnackbar(messageResId = R.string.export_error_generic, formatArgs = listOf(e.localizedMessage ?: "Unknown error"))
}
}
}
// --- Measurement types (via MeasurementFacade) ---
val measurementTypes: StateFlow<List<MeasurementType>> =
measurementFacade.getAllMeasurementTypes()
@@ -339,13 +368,6 @@ class SharedViewModel @Inject constructor(
.map { list -> list.firstOrNull()?.measurementWithValues }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
// --- Time-filtered flow (no smoothing) for current user ---
fun getTimeFilteredEnrichedMeasurements(range: TimeRangeFilter): Flow<List<EnrichedMeasurement>> =
selectedUserId.flatMapLatest { uid ->
if (uid == null) flowOf(emptyList())
else measurementFacade.timeFilteredEnrichedFlow(uid, measurementTypes, range)
}
// --- Full pipeline (time filter + smoothing) for current user ---
val processedMeasurementsFlow: StateFlow<List<EnrichedMeasurement>> =
selectedUserId
@@ -364,28 +386,36 @@ class SharedViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
// --- CRUD delegates (via MeasurementFacade) ---
fun saveMeasurement(measurement: Measurement, values: List<MeasurementValue>) {
viewModelScope.launch(Dispatchers.IO) {
fun getMeasurementById(id: Int) : Flow<MeasurementWithValues?> {
return measurementFacade.getMeasurementWithValuesById(id)
}
suspend fun saveMeasurement(measurement: Measurement, values: List<MeasurementValue>, silent : Boolean = false) : Boolean {
return withContext(Dispatchers.IO) {
val result = measurementFacade.saveMeasurement(measurement, values)
if (result.isSuccess) {
showSnackbar(
if (!silent) showSnackbar(
messageResId = if (measurement.id == 0) R.string.success_measurement_saved
else R.string.success_measurement_updated
)
true
} else {
showSnackbar(messageResId = R.string.error_saving_measurement)
if (!silent) showSnackbar(messageResId = R.string.error_saving_measurement)
false
}
}
}
fun deleteMeasurement(measurement: Measurement) {
viewModelScope.launch(Dispatchers.IO) {
suspend fun deleteMeasurement(measurement: Measurement, silent : Boolean = false) : Boolean {
return withContext(Dispatchers.IO) {
val result = measurementFacade.deleteMeasurement(measurement)
if (result.isSuccess) {
showSnackbar(messageResId = R.string.success_measurement_deleted)
if (!silent) showSnackbar(messageResId = R.string.success_measurement_deleted)
if (_currentMeasurementId.value == measurement.id) _currentMeasurementId.value = null
true
} else {
showSnackbar(messageResId = R.string.error_deleting_measurement)
if (!silent) showSnackbar(messageResId = R.string.error_deleting_measurement)
false
}
}
}

View File

@@ -378,6 +378,21 @@
<string name="table_message_no_data_for_selection">Keine Daten für die aktuelle Spaltenauswahl verfügbar.</string>
<string name="table_trend_up">Trend steigend</string>
<string name="table_trend_down">Trend fallend</string>
<string name="desc_enter_selection_mode">Elemente auswählen</string>
<string name="items_selected_count">%1$d ausgewählt</string>
<string name="desc_cancel_selection_mode">Auswahl abbrechen</string>
<string name="desc_change_user">Nutzer für ausgewählte Elemente wechseln</string>
<string name="desc_export_selected">Ausgewählte Elemente exportieren</string>
<string name="desc_delete_selected">Ausgewählte Elemente löschen</string>
<string name="dialog_title_delete_items">Elemente löschen?</string>
<string name="dialog_message_delete_item">Möchten Sie das ausgewählte Element wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="dialog_message_delete_items">Möchten Sie die ausgewählten %1$d Elemente wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="snackbar_items_deleted_successfully">%1$d Elemente erfolgreich gelöscht.</string>
<string name="snackbar_error_deleting_items">Fehler beim Löschen der Elemente.</string>
<string name="dialog_title_select_user_for_assignment">Nutzer für Zuweisung auswählen</string>
<string name="snackbar_no_other_users_to_change_to">Keine anderen Nutzer vorhanden, zu denen gewechselt werden kann.</string>
<string name="snackbar_items_user_changed_successfully">Nutzer für %1$d Elemente erfolgreich geändert.</string>
<string name="snackbar_error_user_changed_items">Fehler beim Ändern des Nutzers für einige Elemente.</string>
<!-- Statistik-Bildschirm -->
<string name="statistics_no_relevant_types">Keine relevanten Messarten verfügbar oder für Statistiken konfiguriert.</string>

View File

@@ -381,6 +381,21 @@
<string name="table_message_no_data_for_selection">No data available for the current column selection.</string>
<string name="table_trend_up">Trending up</string>
<string name="table_trend_down">Trending down</string>
<string name="desc_enter_selection_mode">Select items</string>
<string name="items_selected_count">%1$d selected</string>
<string name="desc_cancel_selection_mode">Cancel selection</string>
<string name="desc_change_user">Change user for selected items</string>
<string name="desc_export_selected">Export selected items</string>
<string name="desc_delete_selected">Delete selected items</string>
<string name="dialog_title_delete_items">Delete Items?</string>
<string name="dialog_message_delete_item">Are you sure you want to delete the selected item? This action cannot be undone.</string>
<string name="dialog_message_delete_items">Are you sure you want to delete the selected %1$d items? This action cannot be undone.</string>
<string name="snackbar_items_deleted_successfully">%1$d items deleted successfully.</string>
<string name="snackbar_error_deleting_items">Error deleting items.</string>
<string name="dialog_title_select_user_for_assignment">Select User for Assignment</string>
<string name="snackbar_no_other_users_to_change_to">No other users available to change to.</string>
<string name="snackbar_items_user_changed_successfully">User changed for %1$d items successfully.</string>
<string name="snackbar_error_user_changed_items">Error changing user for some items.</string>
<!-- Statistics Screen -->
<string name="statistics_no_relevant_types">No relevant measurement types available or configured for statistics.</string>