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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user