1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-10-28 14:25:17 +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( suspend fun exportUserToCsv(
userId: Int, userId: Int,
uri: Uri, uri: Uri,
resolver: ContentResolver resolver: ContentResolver,
): Result<Int> = importExport.exportUserToCsv(userId, uri, resolver) filterByMeasurementIds: List<Int>? = null
): Result<Int> = importExport.exportUserToCsv(userId, uri, resolver, filterByMeasurementIds)
suspend fun importUserFromCsv( suspend fun importUserFromCsv(
userId: Int, userId: Int,

View File

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

View File

@@ -179,6 +179,7 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
// Collect UI states from SharedViewModel // Collect UI states from SharedViewModel
val topBarTitleFromVM by sharedViewModel.topBarTitle.collectAsState() val topBarTitleFromVM by sharedViewModel.topBarTitle.collectAsState()
val topBarActions by sharedViewModel.topBarActions.collectAsState() val topBarActions by sharedViewModel.topBarActions.collectAsState()
val isInContextualSelectionMode by sharedViewModel.isInContextualSelectionMode.collectAsState()
val allUsers by sharedViewModel.allUsers.collectAsState() val allUsers by sharedViewModel.allUsers.collectAsState()
val selectedUser by sharedViewModel.selectedUser.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. // 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( UserDropdownAsAction(
users = allUsers, users = allUsers,
selectedUser = selectedUser, selectedUser = selectedUser,

View File

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

View File

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

View File

@@ -14,6 +14,10 @@
*/ */
package com.health.openscale.ui.screen.table 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll 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.Icons
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -61,6 +78,7 @@ import androidx.navigation.NavController
import com.health.openscale.R import com.health.openscale.R
import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.EvaluationState
import com.health.openscale.core.data.InputFieldType 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.MeasurementTypeKey
import com.health.openscale.core.data.Trend import com.health.openscale.core.data.Trend
import com.health.openscale.core.data.UnitType 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.screen.components.MeasurementTypeFilterRow
import com.health.openscale.ui.shared.SharedViewModel import com.health.openscale.ui.shared.SharedViewModel
import com.health.openscale.core.utils.LocaleUtils 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 kotlinx.coroutines.launch
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -125,6 +147,7 @@ fun TableScreen(
navController: NavController, navController: NavController,
sharedViewModel: SharedViewModel sharedViewModel: SharedViewModel
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState() val enrichedMeasurements by sharedViewModel.enrichedMeasurementsFlow.collectAsState()
val allAvailableTypesFromVM by sharedViewModel.measurementTypes.collectAsState() val allAvailableTypesFromVM by sharedViewModel.measurementTypes.collectAsState()
@@ -132,12 +155,35 @@ fun TableScreen(
// Column selection state provided by filter row. // Column selection state provided by filter row.
val selectedColumnIdsFromFilter = remember { mutableStateListOf<Int>() } 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 = val displayedTypes =
remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) { remember(allAvailableTypesFromVM, selectedColumnIdsFromFilter.toList()) {
allAvailableTypesFromVM.filter { it.id in selectedColumnIdsFromFilter } 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). // Transform measurements -> table rows (compute eval state & formatted strings here).
val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM, userEvaluationContext) { val tableData = remember(enrichedMeasurements, displayedTypes, allAvailableTypesFromVM, userEvaluationContext) {
if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) { if (enrichedMeasurements.isEmpty() || displayedTypes.isEmpty()) {
@@ -253,8 +299,233 @@ fun TableScreen(
val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection) val noDataForSelectionMessage = stringResource(id = R.string.table_message_no_data_for_selection)
val dateColumnHeader = stringResource(id = R.string.table_header_date) val dateColumnHeader = stringResource(id = R.string.table_header_date)
LaunchedEffect(Unit, tableScreenTitle) { fun deleteSelectedItems(selectedItemIds : List<Int>) {
sharedViewModel.setTopBarTitle(tableScreenTitle) 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() val horizontalScrollState = rememberScrollState()
@@ -271,7 +542,14 @@ fun TableScreen(
onPersistSelectedTypeIds = { idsToSave -> onPersistSelectedTypeIds = { idsToSave ->
scope.launch { sharedViewModel.saveSelectedTableTypeIds(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 -> defaultSelectionLogic = { availableFilteredTypes ->
val defaultDesiredTypeIds = listOf( val defaultDesiredTypeIds = listOf(
MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT.id,
@@ -337,6 +615,40 @@ fun TableScreen(
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically 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( TableHeaderCellInternal(
text = dateColumnHeader, text = dateColumnHeader,
modifier = Modifier modifier = Modifier
@@ -368,27 +680,63 @@ fun TableScreen(
// --- DATA ROWS --- // --- DATA ROWS ---
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
items(tableData, key = { it.measurementId }) { rowData -> items(tableData, key = { it.measurementId }) { rowData ->
val isSelected = selectedItemIds.contains(rowData.measurementId)
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(
if (isSelected && isInSelectionMode) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
}
)
.clickable { .clickable {
navController.navigate( if (isInSelectionMode) {
Routes.measurementDetail( if (isSelected) {
rowData.measurementId, selectedItemIds.remove(rowData.measurementId)
sharedViewModel.selectedUserId.value } else {
selectedItemIds.add(rowData.measurementId)
}
} else {
navController.navigate(
Routes.measurementDetail(
rowData.measurementId,
sharedViewModel.selectedUserId.value
)
) )
) }
} }
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Date cell (fixed column) // 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( TableDataCellInternal(
cellData = null, cellData = null,
fixedText = rowData.formattedTimestamp, fixedText = rowData.formattedTimestamp,
modifier = Modifier modifier = Modifier
.widthIn(min = dateColMin, max = dateColMax) .widthIn(min = dateColMin, max = dateColMax)
.background(MaterialTheme.colorScheme.surface)
.fillMaxHeight(), .fillMaxHeight(),
alignment = TextAlign.Start, alignment = TextAlign.Start,
isDateCell = true isDateCell = true

View File

@@ -17,6 +17,9 @@
*/ */
package com.health.openscale.ui.shared package com.health.openscale.ui.shared
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.ViewModel 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.TimeRangeFilter
import com.health.openscale.core.data.User import com.health.openscale.core.data.User
import com.health.openscale.core.data.UserGoals 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.MeasurementFacade
import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.facade.SettingsFacade
import com.health.openscale.core.facade.UserFacade 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.model.UserEvaluationContext
import com.health.openscale.core.usecase.MeasurementEvaluationResult import com.health.openscale.core.usecase.MeasurementEvaluationResult
import com.health.openscale.core.utils.LogManager 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -52,12 +58,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@@ -71,6 +79,7 @@ import javax.inject.Inject
class SharedViewModel @Inject constructor( class SharedViewModel @Inject constructor(
private val userFacade: UserFacade, private val userFacade: UserFacade,
private val measurementFacade: MeasurementFacade, private val measurementFacade: MeasurementFacade,
private val dataManagementFacade: DataManagementFacade,
private val settingsFacade: SettingsFacade private val settingsFacade: SettingsFacade
) : ViewModel(), SettingsFacade by settingsFacade { ) : ViewModel(), SettingsFacade by settingsFacade {
companion object { companion object {
@@ -94,6 +103,13 @@ class SharedViewModel @Inject constructor(
fun setTopBarAction(action: TopBarAction?) { _topBarActions.value = if (action != null) listOf(action) else emptyList() } fun setTopBarAction(action: TopBarAction?) { _topBarActions.value = if (action != null) listOf(action) else emptyList() }
fun setTopBarActions(actions: List<TopBarAction>) { _topBarActions.value = actions } 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 --- // --- Snackbar events ---
private val _snackbarEvents = MutableSharedFlow<SnackbarEvent>(replay = 0, extraBufferCapacity = 1) private val _snackbarEvents = MutableSharedFlow<SnackbarEvent>(replay = 0, extraBufferCapacity = 1)
val snackbarEvents: SharedFlow<SnackbarEvent> = _snackbarEvents.asSharedFlow() 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) --- // --- Measurement types (via MeasurementFacade) ---
val measurementTypes: StateFlow<List<MeasurementType>> = val measurementTypes: StateFlow<List<MeasurementType>> =
measurementFacade.getAllMeasurementTypes() measurementFacade.getAllMeasurementTypes()
@@ -339,13 +368,6 @@ class SharedViewModel @Inject constructor(
.map { list -> list.firstOrNull()?.measurementWithValues } .map { list -> list.firstOrNull()?.measurementWithValues }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .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 --- // --- Full pipeline (time filter + smoothing) for current user ---
val processedMeasurementsFlow: StateFlow<List<EnrichedMeasurement>> = val processedMeasurementsFlow: StateFlow<List<EnrichedMeasurement>> =
selectedUserId selectedUserId
@@ -364,28 +386,36 @@ class SharedViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
// --- CRUD delegates (via MeasurementFacade) --- // --- CRUD delegates (via MeasurementFacade) ---
fun saveMeasurement(measurement: Measurement, values: List<MeasurementValue>) { fun getMeasurementById(id: Int) : Flow<MeasurementWithValues?> {
viewModelScope.launch(Dispatchers.IO) { 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) val result = measurementFacade.saveMeasurement(measurement, values)
if (result.isSuccess) { if (result.isSuccess) {
showSnackbar( if (!silent) showSnackbar(
messageResId = if (measurement.id == 0) R.string.success_measurement_saved messageResId = if (measurement.id == 0) R.string.success_measurement_saved
else R.string.success_measurement_updated else R.string.success_measurement_updated
) )
true
} else { } else {
showSnackbar(messageResId = R.string.error_saving_measurement) if (!silent) showSnackbar(messageResId = R.string.error_saving_measurement)
false
} }
} }
} }
fun deleteMeasurement(measurement: Measurement) { suspend fun deleteMeasurement(measurement: Measurement, silent : Boolean = false) : Boolean {
viewModelScope.launch(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val result = measurementFacade.deleteMeasurement(measurement) val result = measurementFacade.deleteMeasurement(measurement)
if (result.isSuccess) { 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 if (_currentMeasurementId.value == measurement.id) _currentMeasurementId.value = null
true
} else { } 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_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_up">Trend steigend</string>
<string name="table_trend_down">Trend fallend</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 --> <!-- Statistik-Bildschirm -->
<string name="statistics_no_relevant_types">Keine relevanten Messarten verfügbar oder für Statistiken konfiguriert.</string> <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_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_up">Trending up</string>
<string name="table_trend_down">Trending down</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 --> <!-- Statistics Screen -->
<string name="statistics_no_relevant_types">No relevant measurement types available or configured for statistics.</string> <string name="statistics_no_relevant_types">No relevant measurement types available or configured for statistics.</string>