diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt index 783374e6..7e933950 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt @@ -90,8 +90,9 @@ class DataManagementFacade @Inject constructor( suspend fun exportUserToCsv( userId: Int, uri: Uri, - resolver: ContentResolver - ): Result = importExport.exportUserToCsv(userId, uri, resolver) + resolver: ContentResolver, + filterByMeasurementIds: List? = null + ): Result = importExport.exportUserToCsv(userId, uri, resolver, filterByMeasurementIds) suspend fun importUserFromCsv( userId: Int, diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt index d431499f..c89e17fc 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt @@ -91,7 +91,8 @@ class ImportExportUseCases @Inject constructor( suspend fun exportUserToCsv( userId: Int, uri: Uri, - contentResolver: ContentResolver + contentResolver: ContentResolver, + filterByMeasurementIds: List? = null ): Result = runCatching { LogManager.i(TAG, "CSV export for userId=$userId -> $uri") @@ -120,15 +121,21 @@ class ImportExportUseCases @Inject constructor( addAll(valueColumnKeys.sorted()) } - val userMeasurementsWithValues: List = + val allUserMeasurementsWithValues: List = 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>() - userMeasurementsWithValues.forEach { mwv -> + filteredUserMeasurementsWithValues.forEach { mwv -> val zdt = Instant.ofEpochMilli(mwv.measurement.timestamp).atZone(ZoneId.systemDefault()) val row = mutableMapOf( dateColumnKey to dateFormatter.format(zdt), diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index 4318082c..5e4daa7a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -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, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt index 67494bea..05433c1a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -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() } @@ -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() diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index 809caac3..471406cc 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -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) ) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt index 993f2a7b..42b49da0 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -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() } + var isInSelectionMode by rememberSaveable { mutableStateOf(false) } + val selectedItemIds = remember { mutableStateListOf() } + 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) { + 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) { + 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, 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() + + 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() + 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 diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index ff8ee5a8..adbb5ed4 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -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) { _topBarActions.value = actions } + private val _isInContextualSelectionMode = MutableStateFlow(false) + val isInContextualSelectionMode: StateFlow = _isInContextualSelectionMode.asStateFlow() + + fun setContextualSelectionMode(isActive: Boolean) { + _isInContextualSelectionMode.value = isActive + } + // --- Snackbar events --- private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val snackbarEvents: SharedFlow = _snackbarEvents.asSharedFlow() @@ -280,6 +296,19 @@ class SharedViewModel @Inject constructor( } } + fun performCsvExport(userId: Int, uri: Uri, contentResolver: ContentResolver, filterByMeasurementIds: List? = 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> = 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> = - 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> = 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) { - viewModelScope.launch(Dispatchers.IO) { + fun getMeasurementById(id: Int) : Flow { + return measurementFacade.getMeasurementWithValuesById(id) + } + + suspend fun saveMeasurement(measurement: Measurement, values: List, 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 } } } diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 08606e23..37ad2a69 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -378,6 +378,21 @@ Keine Daten für die aktuelle Spaltenauswahl verfügbar. Trend steigend Trend fallend + Elemente auswählen + %1$d ausgewählt + Auswahl abbrechen + Nutzer für ausgewählte Elemente wechseln + Ausgewählte Elemente exportieren + Ausgewählte Elemente löschen + Elemente löschen? + Möchten Sie das ausgewählte Element wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + Möchten Sie die ausgewählten %1$d Elemente wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + %1$d Elemente erfolgreich gelöscht. + Fehler beim Löschen der Elemente. + Nutzer für Zuweisung auswählen + Keine anderen Nutzer vorhanden, zu denen gewechselt werden kann. + Nutzer für %1$d Elemente erfolgreich geändert. + Fehler beim Ändern des Nutzers für einige Elemente. Keine relevanten Messarten verfügbar oder für Statistiken konfiguriert. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index f2c125da..f802720c 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -381,6 +381,21 @@ No data available for the current column selection. Trending up Trending down + Select items + %1$d selected + Cancel selection + Change user for selected items + Export selected items + Delete selected items + Delete Items? + Are you sure you want to delete the selected item? This action cannot be undone. + Are you sure you want to delete the selected %1$d items? This action cannot be undone. + %1$d items deleted successfully. + Error deleting items. + Select User for Assignment + No other users available to change to. + User changed for %1$d items successfully. + Error changing user for some items. No relevant measurement types available or configured for statistics.