From 8f7c15e0c59b458dda303a01e561a0857e173761 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 17 Aug 2025 19:25:01 +0200 Subject: [PATCH] Implement Automatic Database Backups --- android_app/app/build.gradle.kts | 2 + android_app/app/src/main/AndroidManifest.xml | 11 + .../java/com/health/openscale/MainActivity.kt | 77 +- .../java/com/health/openscale/OpenScaleApp.kt | 152 ++++ .../com/health/openscale/core/data/Enums.kt | 15 + .../core/database/DatabaseRepository.kt | 4 + .../core/database/UserSettingsRepository.kt | 114 +++ .../openscale/core/worker/BackupWorker.kt | 132 +++ .../core/worker/TaskWorkerFactory.kt | 72 ++ .../settings/DataManagementSettingsScreen.kt | 800 ++++++++++-------- .../ui/screen/settings/SettingsViewModel.kt | 103 ++- .../app/src/main/res/values-de/strings.xml | 38 + .../app/src/main/res/values/strings.xml | 38 + android_app/gradle/libs.versions.toml | 18 +- 14 files changed, 1118 insertions(+), 458 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/worker/TaskWorkerFactory.kt diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index 66749a86..e9312d82 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -147,6 +147,8 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.worker) + implementation(libs.androidx.documentfile) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml index 70cc50b6..f7235b98 100644 --- a/android_app/app/src/main/AndroidManifest.xml +++ b/android_app/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + + + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt index bdd74a8a..152e3cab 100644 --- a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt +++ b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt @@ -50,44 +50,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -/** - * Generates a default list of measurement types available in the application, - * resolving names from string resources. - * These types are intended for insertion into the database on the first app start. - * - * @param context The context used to access string resources. - * @return A list of [MeasurementType] objects. - */ -fun getDefaultMeasurementTypes(): List { - return listOf( - MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFF7E57C2.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isPinned = true, isEnabled = true, isOnRightYAxis = true), - MeasurementType(key = MeasurementTypeKey.BMI, unit = UnitType.NONE, color = 0xFFFFCA28.toInt(), icon = MeasurementTypeIcon.IC_BMI, isDerived = true, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.HIPS, unit = UnitType.CM, color = 0xFF5C6BC0.toInt(), icon = MeasurementTypeIcon.IC_HIPS, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, unit = UnitType.NONE, color = 0xFFD84315.toInt(), icon = MeasurementTypeIcon.IC_VISCERAL_FAT, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CHEST, unit = UnitType.CM, color = 0xFF8E24AA.toInt(), icon = MeasurementTypeIcon.IC_CHEST, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.THIGH, unit = UnitType.CM, color = 0xFFA1887F.toInt(), icon = MeasurementTypeIcon.IC_THIGH, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BICEPS, unit = UnitType.CM, color = 0xFFEC407A.toInt(), icon = MeasurementTypeIcon.IC_BICEPS, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.NECK, unit = UnitType.CM, color = 0xFFB0BEC5.toInt(), icon = MeasurementTypeIcon.IC_NECK, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_1, unit = UnitType.CM, color = 0xFFFFF59D.toInt(), icon = MeasurementTypeIcon.IC_CALIPER1, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_2, unit = UnitType.CM, color = 0xFFFFE082.toInt(), icon = MeasurementTypeIcon.IC_CALIPER2, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_3, unit = UnitType.CM, color = 0xFFFFCC80.toInt(), icon = MeasurementTypeIcon.IC_CALIPER3, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER, unit = UnitType.PERCENT, color = 0xFFFB8C00.toInt(), icon = MeasurementTypeIcon.IC_FAT_CALIPER, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BMR, unit = UnitType.KCAL, color = 0xFFAB47BC.toInt(), icon = MeasurementTypeIcon.IC_BMR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.TDEE, unit = UnitType.KCAL, color = 0xFF26A69A.toInt(), icon = MeasurementTypeIcon.IC_TDEE, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF757575.toInt(), icon = MeasurementTypeIcon.IC_TIME, isEnabled = true) - ) -} /** * The main entry point of the application. @@ -99,21 +61,13 @@ class MainActivity : ComponentActivity() { private const val TAG = "MainActivity" } - private lateinit var userSettingsRepository: UserSettingsRepository // Machen Sie es zur Property + private val appInstance: OpenScaleApp by lazy { application as OpenScaleApp } + private val userSettingsRepository: UserSettingsRepository by lazy { appInstance.userSettingsRepository } + private val databaseRepository: DatabaseRepository by lazy { appInstance.databaseRepository } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - userSettingsRepository = provideUserSettingsRepository(applicationContext) - - // --- LogManager initializing --- - lifecycleScope.launch { - val isFileLoggingEnabled = runCatching { userSettingsRepository.isFileLoggingEnabled.first() } - .getOrElse { false } - LogManager.init(applicationContext, isFileLoggingEnabled) - LogManager.d(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled") - } - // --- Language initializing --- lifecycleScope.launch { userSettingsRepository.appLanguageCode.collectLatest { languageCode -> @@ -139,30 +93,7 @@ class MainActivity : ComponentActivity() { } private fun initializeAndSetContent() { - val db = AppDatabase.getInstance(applicationContext) - val databaseRepository = DatabaseRepository( - database = db, - userDao = db.userDao(), - measurementDao = db.measurementDao(), - measurementValueDao = db.measurementValueDao(), - measurementTypeDao = db.measurementTypeDao() - ) - - // --- Measurement Types initializing --- - CoroutineScope(Dispatchers.IO).launch { - val isActuallyFirstStart = userSettingsRepository.isFirstAppStart.first() - LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isActuallyFirstStart") - if (isActuallyFirstStart) { - LogManager.i(TAG, "First app start detected. Inserting default measurement types...") - val defaultTypesToInsert = getDefaultMeasurementTypes() - db.measurementTypeDao().insertAll(defaultTypesToInsert) - userSettingsRepository.setFirstAppStartCompleted(false) - LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.") - } else { - LogManager.d(TAG, "Not the first app start. Default data should already exist.") - } - } - + LogManager.d(TAG, "Initializing and setting content.") enableEdgeToEdge() setContent { diff --git a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt new file mode 100644 index 00000000..21c740df --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt @@ -0,0 +1,152 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale + +import android.app.Application +import android.util.Log +import androidx.work.Configuration +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.UnitType +import com.health.openscale.core.database.AppDatabase +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.database.UserSettingsRepository +import com.health.openscale.core.database.provideUserSettingsRepository +import com.health.openscale.core.utils.LogManager +import com.health.openscale.core.worker.TaskWorkerFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Generates a default list of measurement types available in the application, + * resolving names from string resources. + * These types are intended for insertion into the database on the first app start. + * + * @param context The context used to access string resources. + * @return A list of [MeasurementType] objects. + */ +fun getDefaultMeasurementTypes(): List { + return listOf( + MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFF7E57C2.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isPinned = true, isEnabled = true, isOnRightYAxis = true), + MeasurementType(key = MeasurementTypeKey.BMI, unit = UnitType.NONE, color = 0xFFFFCA28.toInt(), icon = MeasurementTypeIcon.IC_BMI, isDerived = true, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.HIPS, unit = UnitType.CM, color = 0xFF5C6BC0.toInt(), icon = MeasurementTypeIcon.IC_HIPS, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, unit = UnitType.NONE, color = 0xFFD84315.toInt(), icon = MeasurementTypeIcon.IC_VISCERAL_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CHEST, unit = UnitType.CM, color = 0xFF8E24AA.toInt(), icon = MeasurementTypeIcon.IC_CHEST, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.THIGH, unit = UnitType.CM, color = 0xFFA1887F.toInt(), icon = MeasurementTypeIcon.IC_THIGH, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BICEPS, unit = UnitType.CM, color = 0xFFEC407A.toInt(), icon = MeasurementTypeIcon.IC_BICEPS, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.NECK, unit = UnitType.CM, color = 0xFFB0BEC5.toInt(), icon = MeasurementTypeIcon.IC_NECK, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_1, unit = UnitType.CM, color = 0xFFFFF59D.toInt(), icon = MeasurementTypeIcon.IC_CALIPER1, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_2, unit = UnitType.CM, color = 0xFFFFE082.toInt(), icon = MeasurementTypeIcon.IC_CALIPER2, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER_3, unit = UnitType.CM, color = 0xFFFFCC80.toInt(), icon = MeasurementTypeIcon.IC_CALIPER3, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALIPER, unit = UnitType.PERCENT, color = 0xFFFB8C00.toInt(), icon = MeasurementTypeIcon.IC_FAT_CALIPER, isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BMR, unit = UnitType.KCAL, color = 0xFFAB47BC.toInt(), icon = MeasurementTypeIcon.IC_BMR, isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.TDEE, unit = UnitType.KCAL, color = 0xFF26A69A.toInt(), icon = MeasurementTypeIcon.IC_TDEE, isDerived = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF757575.toInt(), icon = MeasurementTypeIcon.IC_TIME, isEnabled = true) + ) +} + +class OpenScaleApp : Application(), Configuration.Provider { + companion object { + private const val TAG = "OpenScaleApp" + } + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val userSettingsRepository: UserSettingsRepository by lazy { + provideUserSettingsRepository(applicationContext) + } + + val databaseRepository: DatabaseRepository by lazy { + val db = AppDatabase.getInstance(applicationContext) + DatabaseRepository( + database = db, + userDao = db.userDao(), + measurementDao = db.measurementDao(), + measurementValueDao = db.measurementValueDao(), + measurementTypeDao = db.measurementTypeDao() + ) + } + + + override fun onCreate() { + super.onCreate() + + initializeLogging() + initializeDefaultData() + } + + private fun initializeLogging() { + applicationScope.launch { + val isFileLoggingEnabled = try { + userSettingsRepository.isFileLoggingEnabled.first() + } catch (e: Exception) { + // Log to standard Android Log if our LogManager or DataStore fails early + Log.e(TAG, "Failed to retrieve isFileLoggingEnabled setting", e) + false + } + LogManager.init(applicationContext, isFileLoggingEnabled) + LogManager.i(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled") + } + } + + private fun initializeDefaultData() { + applicationScope.launch(Dispatchers.IO) { // Use IO dispatcher for database operations + try { + val isFirstActualStart = userSettingsRepository.isFirstAppStart.first() + LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isFirstActualStart") + + if (isFirstActualStart) { + LogManager.i(TAG, "First app start detected. Inserting default measurement types...") + databaseRepository.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + userSettingsRepository.setFirstAppStartCompleted(false) + LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.") + } else { + LogManager.d(TAG, "Not the first app start. Default data should already exist.") + } + } catch (e: Exception) { + LogManager.e(TAG, "Error during first-start data initialization", e) + } + } + } + + + override val workManagerConfiguration: Configuration by lazy { + val factory = TaskWorkerFactory( + userSettingsRepository = userSettingsRepository, + databaseRepository = databaseRepository + ) + Configuration.Builder() + .setWorkerFactory(factory) + .build() + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index a746fcea..c68cec32 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.data +import android.content.Context import android.text.InputType import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -349,4 +350,18 @@ enum class SmoothingAlgorithm(@StringRes val displayNameResId: Int) { fun getDisplayName(context: android.content.Context): String { return context.getString(displayNameResId) } +} + +enum class BackupInterval { + DAILY, + WEEKLY, + MONTHLY; + + fun getDisplayName(context: Context): String { + return when (this) { + DAILY -> context.getString(R.string.interval_daily) + WEEKLY -> context.getString(R.string.interval_weekly) + MONTHLY -> context.getString(R.string.interval_monthly) + } + } } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt index 93440852..cf7612f1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt @@ -188,6 +188,10 @@ class DatabaseRepository( // --- Measurement Type Operations --- + suspend fun insertAllMeasurementTypes(types: List) { + measurementTypeDao.insertAll(types) + } + fun getAllMeasurementTypes(): Flow> = measurementTypeDao.getAll() suspend fun insertMeasurementType(type: MeasurementType): Long { diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt index ee7f01ee..077e0425 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt @@ -30,6 +30,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.utils.LogManager import kotlinx.coroutines.flow.Flow @@ -64,6 +65,13 @@ object UserPreferenceKeys { val CHART_SMOOTHING_ALPHA = floatPreferencesKey("chart_smoothing_alpha") val CHART_SMOOTHING_WINDOW_SIZE = intPreferencesKey("chart_smoothing_window_size") + // --- Settings for Automatic Backups --- + val AUTO_BACKUP_ENABLED_GLOBALLY = booleanPreferencesKey("auto_backup_enabled_globally") + val AUTO_BACKUP_LOCATION_URI = stringPreferencesKey("auto_backup_location_uri") + val AUTO_BACKUP_INTERVAL = stringPreferencesKey("auto_backup_interval") + val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file") + val AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP = longPreferencesKey("auto_backup_last_successful_timestamp") + // Context strings for screen-specific settings (can be used as prefixes for dynamic keys) const val OVERVIEW_SCREEN_CONTEXT = "overview_screen" const val GRAPH_SCREEN_CONTEXT = "graph_screen" @@ -109,6 +117,22 @@ interface UserSettingsRepository { val chartSmoothingWindowSize: Flow suspend fun setChartSmoothingWindowSize(windowSize: Int) + // --- Automatic Backup Settings --- + val autoBackupEnabledGlobally: Flow + suspend fun setAutoBackupEnabledGlobally(enabled: Boolean) + + val autoBackupLocationUri: Flow + suspend fun setAutoBackupLocationUri(uri: String?) + + val autoBackupInterval: Flow + suspend fun setAutoBackupInterval(interval: BackupInterval) + + val autoBackupCreateNewFile: Flow + suspend fun setAutoBackupCreateNewFile(createNew: Boolean) + + val autoBackupLastSuccessfulTimestamp: Flow + suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long) + // Generic Settings Accessors /** * Observes a setting with the given key name and default value. @@ -333,6 +357,96 @@ class UserSettingsRepositoryImpl(context: Context) : UserSettingsRepository { saveSetting(UserPreferenceKeys.CHART_SMOOTHING_WINDOW_SIZE.name, validWindowSize) } + override val autoBackupEnabledGlobally: Flow = observeSetting( + UserPreferenceKeys.AUTO_BACKUP_ENABLED_GLOBALLY.name, + false // Standardmäßig deaktiviert + ).catch { exception -> + LogManager.e(TAG, "Error observing autoBackupEnabledGlobally", exception) + emit(false) + } + + override suspend fun setAutoBackupEnabledGlobally(enabled: Boolean) { + LogManager.d(TAG, "Setting autoBackupEnabledGlobally to: $enabled") + saveSetting(UserPreferenceKeys.AUTO_BACKUP_ENABLED_GLOBALLY.name, enabled) + } + + override val autoBackupLocationUri: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading autoBackupLocationUri from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI] + } + .distinctUntilChanged() + + override suspend fun setAutoBackupLocationUri(uri: String?) { + LogManager.d(TAG, "Setting autoBackupLocationUri to: $uri") + dataStore.edit { preferences -> + if (uri != null) { + preferences[UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI] = uri + } else { + preferences.remove(UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI) + } + } + } + + override val autoBackupInterval: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading autoBackupInterval from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + val intervalName = preferences[UserPreferenceKeys.AUTO_BACKUP_INTERVAL] + try { + intervalName?.let { BackupInterval.valueOf(it) } ?: BackupInterval.WEEKLY + } catch (e: IllegalArgumentException) { + LogManager.w(TAG, "Invalid BackupInterval name '$intervalName' in DataStore. Defaulting to WEEKLY.", e) + BackupInterval.WEEKLY + } + } + .distinctUntilChanged() + + override suspend fun setAutoBackupInterval(interval: BackupInterval) { + LogManager.d(TAG, "Setting autoBackupInterval to: ${interval.name}") + saveSetting(UserPreferenceKeys.AUTO_BACKUP_INTERVAL.name, interval.name) + } + + + override val autoBackupCreateNewFile: Flow = observeSetting( + UserPreferenceKeys.AUTO_BACKUP_CREATE_NEW_FILE.name, + false + ).catch { exception -> + LogManager.e(TAG, "Error observing autoBackupCreateNewFile", exception) + emit(false) + } + + override suspend fun setAutoBackupCreateNewFile(createNew: Boolean) { + LogManager.d(TAG, "Setting autoBackupCreateNewFile to: $createNew") + saveSetting(UserPreferenceKeys.AUTO_BACKUP_CREATE_NEW_FILE.name, createNew) + } + + override val autoBackupLastSuccessfulTimestamp: Flow = observeSetting( + UserPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name, + 0L + ).catch { exception -> + LogManager.e(TAG, "Error observing autoBackupLastSuccessfulTimestamp", exception) + emit(0L) // Fallback + } + + override suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long) { + LogManager.d(TAG, "Setting autoBackupLastSuccessfulTimestamp to: $timestamp") + saveSetting(UserPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name, timestamp) + } + @Suppress("UNCHECKED_CAST") override fun observeSetting(keyName: String, defaultValue: T): Flow { LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'") diff --git a/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt b/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt new file mode 100644 index 00000000..a24b7ca7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt @@ -0,0 +1,132 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.worker + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.database.UserSettingsRepository +import com.health.openscale.core.utils.LogManager +import kotlinx.coroutines.flow.first +import java.io.File +import java.io.FileInputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + + +class BackupWorker( + appContext: Context, + workerParams: WorkerParameters, + private val userSettingsRepository: UserSettingsRepository, + private val databaseRepository: DatabaseRepository +) : CoroutineWorker(appContext, workerParams) { + companion object { + const val TAG = "AutoBackupWorker" + const val WORK_NAME = "com.health.openscale.AUTO_DATABASE_BACKUP" + } + + override suspend fun doWork(): Result { + LogManager.i(TAG, "Automatic backup worker started.") + + val isEnabled = userSettingsRepository.autoBackupEnabledGlobally.first() + val locationUriString = userSettingsRepository.autoBackupLocationUri.first() + val createNewFile = userSettingsRepository.autoBackupCreateNewFile.first() + + if (!isEnabled || locationUriString == null) { + LogManager.i(TAG, "Auto backup is disabled or location not set. Worker finishing.") + return Result.success() + } + + val backupDirUri = Uri.parse(locationUriString) + val parentDocumentFile = DocumentFile.fromTreeUri(applicationContext, backupDirUri) + + if (parentDocumentFile == null || !parentDocumentFile.canWrite()) { + LogManager.e(TAG, "Cannot write to backup location: $locationUriString. Permissions might be lost or URI invalid.") + userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(0L) + return Result.failure() + } + + try { + val dbName = databaseRepository.getDatabaseName() + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val baseBackupFileName = "${dbName}_auto_backup" + val finalFileName = if (createNewFile) "${baseBackupFileName}_$timeStamp.zip" else "$baseBackupFileName.zip" + + var backupDocumentFile = parentDocumentFile.findFile(finalFileName) + if (backupDocumentFile != null && backupDocumentFile.exists()) { + if (createNewFile) { + LogManager.w(TAG, "File $finalFileName already exists, but createNewFile is true. Creating with new timestamp again.") + } else { + if (!backupDocumentFile.delete()) { + LogManager.e(TAG, "Could not delete existing file $finalFileName for overwrite.") + return Result.failure() + } + backupDocumentFile = null + } + } + + if (backupDocumentFile == null) { + backupDocumentFile = parentDocumentFile.createFile("application/zip", finalFileName) + } + + if (backupDocumentFile == null) { + LogManager.e(TAG, "Could not create backup file: $finalFileName in $locationUriString") + return Result.failure() + } + + applicationContext.contentResolver.openOutputStream(backupDocumentFile.uri)?.use { outputStream -> + ZipOutputStream(outputStream).use { zipOutputStream -> + val dbFile = applicationContext.getDatabasePath(dbName) + val dbDir = dbFile.parentFile ?: return Result.failure() + + val filesToBackup = listOfNotNull( + dbFile, + File(dbDir, "$dbName-shm"), + File(dbDir, "$dbName-wal") + ) + + filesToBackup.forEach { file -> + if (file.exists() && file.isFile) { + FileInputStream(file).use { fileInputStream -> + val entry = ZipEntry(file.name) + zipOutputStream.putNextEntry(entry) + fileInputStream.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + } + } + } ?: return Result.failure() + + LogManager.i(TAG, "Automatic backup successful to: ${backupDocumentFile.uri}") + userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(System.currentTimeMillis()) + return Result.success() + + } catch (e: Exception) { + LogManager.e(TAG, "Error during automatic backup", e) + userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(0L) + return Result.failure() + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/worker/TaskWorkerFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/worker/TaskWorkerFactory.kt new file mode 100644 index 00000000..5ba417f0 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/worker/TaskWorkerFactory.kt @@ -0,0 +1,72 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.worker + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.database.UserSettingsRepository + +/** + * Custom [WorkerFactory] for creating worker instances with injected dependencies. + * + * This factory is provided to WorkManager during its initialization (via [androidx.work.Configuration.Builder.setWorkerFactory]) + * to allow construction of workers that require dependencies beyond the default `Context` and `WorkerParameters`. + */ +class TaskWorkerFactory( + // Dependencies provided by the Application class, to be passed to the workers. + private val userSettingsRepository: UserSettingsRepository, + private val databaseRepository: DatabaseRepository +) : WorkerFactory() { + + /** + * Called by WorkManager to create an instance of a [ListenableWorker]. + * + * @param appContext The application [Context]. + * @param workerClassName The fully-qualified class name of the worker to create. + * @param workerParameters The [WorkerParameters] for the worker. + * @return An instance of the [ListenableWorker], or `null` if this factory + * cannot create the requested worker type. + */ + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return when (workerClassName) { + BackupWorker::class.java.name -> + BackupWorker( + appContext, + workerParameters, + userSettingsRepository, + databaseRepository + ) + + // Add other 'when' branches here if you have other custom workers + // that this factory should create. + // e.g.: + // AnotherCustomWorker::class.java.name -> + // AnotherCustomWorker(appContext, workerParameters, someOtherDependency) + + else -> + null + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt index 28b4b825..f011b4ed 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt @@ -17,11 +17,17 @@ */ package com.health.openscale.ui.screen.settings +import android.content.ActivityNotFoundException +import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,12 +35,21 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults @@ -43,8 +58,11 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -53,6 +71,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 @@ -62,24 +81,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.data.User +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import androidx.core.net.toUri /** * Represents items in the data management settings list. - * Can be an action item or a header. */ sealed class DataManagementSettingListItem { /** * Represents an actionable item in the settings list. - * @param label The text label for the item. - * @param icon The icon for the item. - * @param onClick The lambda to execute when the item is clicked. - * @param enabled Whether the item is clickable and interactive. - * @param isDestructive If true, indicates a potentially dangerous action, often styled differently (e.g., with error colors). - * @param isLoading If true, shows a loading indicator instead of the icon, and the item might be disabled. */ data class ActionItem( val label: String, @@ -92,16 +111,14 @@ sealed class DataManagementSettingListItem { } /** - * Composable screen for managing application data, including import/export of measurements, - * database backup/restore, and deletion of user data or the entire database. - * - * @param navController The NavController for navigation purposes (currently not used in this specific screen's internal logic but good for context). - * @param settingsViewModel The ViewModel that handles the business logic for data management operations. + * Composable screen for managing application data. + * Allows users to export/import data, backup/restore the database, + * and manage automatic backup settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DataManagementSettingsScreen( - navController: NavController, // Not directly used in this composable's logic but passed for potential future use or consistency + navController: NavController, settingsViewModel: SettingsViewModel ) { val users by settingsViewModel.allUsers.collectAsState() @@ -119,90 +136,178 @@ fun DataManagementSettingsScreen( val isAnyOperationLoading = isLoadingExport || isLoadingImport || isLoadingDeletion || isLoadingBackup || isLoadingRestore || isLoadingEntireDatabaseDeletion - // States for the deletion process val showUserSelectionDialogForDelete by settingsViewModel.showUserSelectionDialogForDelete.collectAsState() val userPendingDeletion by settingsViewModel.userPendingDeletion.collectAsState() val showDeleteConfirmationDialog by settingsViewModel.showDeleteConfirmationDialog.collectAsState() var showRestoreConfirmationDialog by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current - var activeSafActionUserId by remember { mutableStateOf(null) } // Stores user ID for SAF actions like CSV export/import + val coroutineScope = rememberCoroutineScope() + + // --- Automatic Backup Settings from ViewModel --- + val autoBackupGloballyEnabled by settingsViewModel.autoBackupEnabledGlobally.collectAsState() + val autoBackupLocationUriString by settingsViewModel.autoBackupLocationUri.collectAsState() + val autoBackupInterval by settingsViewModel.autoBackupInterval.collectAsState() + val autoBackupCreateNewFile by settingsViewModel.autoBackupCreateNewFile.collectAsState() + val autoBackupLastSuccessfulTimestamp by settingsViewModel.autoBackupLastSuccessfulTimestamp.collectAsState() + val isAutoBackupLocationConfigured = autoBackupLocationUriString != null + + // Effective state: global switch is on AND a location is configured. + val isAutoBackupEffectivelyEnabled by remember(autoBackupGloballyEnabled, isAutoBackupLocationConfigured) { + mutableStateOf(autoBackupGloballyEnabled && isAutoBackupLocationConfigured) + } + + + val lastBackupStatusText by remember( + isAutoBackupEffectivelyEnabled, + autoBackupLocationUriString, + autoBackupGloballyEnabled, + autoBackupLastSuccessfulTimestamp, + context + ) { + mutableStateOf( + if (isAutoBackupEffectivelyEnabled) { + if (autoBackupLastSuccessfulTimestamp > 0L) { + val timestamp = autoBackupLastSuccessfulTimestamp + val date = Date(timestamp) + val dateFormat = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + val formattedTime = dateFormat.format(date) + context.getString(R.string.settings_last_backup_status_successful, formattedTime) + } else { + context.getString(R.string.settings_last_backup_status_never) + } + } else if (autoBackupGloballyEnabled && !isAutoBackupLocationConfigured) { + context.getString(R.string.settings_backup_location_not_configured_for_auto) + } else { + context.getString(R.string.settings_auto_backups_disabled) + } + ) + } + + val selectedBackupIntervalDisplay = remember(autoBackupInterval, context) { + autoBackupInterval.getDisplayName(context) + } + var showBackupIntervalDialog by remember { mutableStateOf(false) } + + val backupBehaviorSupportingText by remember(autoBackupCreateNewFile, context) { + mutableStateOf( + if (autoBackupCreateNewFile) context.getString(R.string.settings_backup_behavior_new_file) + else context.getString(R.string.settings_backup_behavior_overwrite) + ) + } + + val currentBackupLocationUserDisplay by remember(autoBackupLocationUriString, context) { + mutableStateOf( + if (autoBackupLocationUriString != null) { + try { + DocumentFile.fromTreeUri(context, Uri.parse(autoBackupLocationUriString!!))?.name + ?: context.getString(R.string.settings_backup_location_selected_folder) + } catch (e: Exception) { + context.getString(R.string.settings_backup_location_error_accessing) + } + } else { + context.getString(R.string.settings_backup_location_not_configured) + } + ) + } + + val canOpenSelectedBackupLocation by remember(autoBackupLocationUriString) { + mutableStateOf(autoBackupLocationUriString != null) + } + + var activeSafActionUserId by remember { mutableStateOf(null) } - // --- ActivityResultLauncher for CSV Export --- val exportCsvLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("text/csv"), onResult = { uri: Uri? -> uri?.let { fileUri -> activeSafActionUserId?.let { userId -> settingsViewModel.performCsvExport(userId, fileUri, context.contentResolver) - activeSafActionUserId = null // Reset after use + activeSafActionUserId = null } } } ) - // --- ActivityResultLauncher for CSV Import --- val importCsvLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), onResult = { uri: Uri? -> uri?.let { fileUri -> activeSafActionUserId?.let { userId -> settingsViewModel.performCsvImport(userId, fileUri, context.contentResolver) - activeSafActionUserId = null // Reset after use + activeSafActionUserId = null } } } ) - // --- ActivityResultLauncher for DB Backup --- - val backupDbLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("*/*"), // Allow any file type as we suggest the name + val manualBackupDbLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*"), // Using generic MIME type for DB backup onResult = { uri: Uri? -> uri?.let { fileUri -> - // activeSafActionUserId is not relevant here as it's a global backup. settingsViewModel.performDatabaseBackup(fileUri, context.applicationContext, context.contentResolver) } } ) - // --- ActivityResultLauncher for DB Restore --- val restoreDbLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), onResult = { uri: Uri? -> uri?.let { fileUri -> - // activeSafActionUserId is not relevant here. + // Confirmation dialog is shown before launching, restore directly settingsViewModel.performDatabaseRestore(fileUri, context.applicationContext, context.contentResolver) } } ) - // Collect SAF events from ViewModel to trigger file pickers + val selectAutoBackupDirectoryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + onResult = { uri: Uri? -> + if (uri != null) { + coroutineScope.launch { + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + settingsViewModel.setAutoBackupLocationUri(context.applicationContext,uri.toString()) + // If user selects a folder, enable auto backups globally if not already. + if (!autoBackupGloballyEnabled) { + settingsViewModel.setAutoBackupEnabledGlobally(context.applicationContext,true) + } + } + Toast.makeText(context, context.getString(R.string.settings_backup_location_selected_toast, + DocumentFile.fromTreeUri(context, uri)?.name ?: "Selected folder"), Toast.LENGTH_SHORT).show() + } else { + // User cancelled or no folder selected + if (!isAutoBackupLocationConfigured) { // Only if no location was configured before + coroutineScope.launch { settingsViewModel.setAutoBackupEnabledGlobally(context.applicationContext,false) } + } + Toast.makeText(context, R.string.settings_backup_location_selection_cancelled, Toast.LENGTH_SHORT).show() + } + } + ) + LaunchedEffect(key1 = settingsViewModel) { settingsViewModel.safEvent.collect { event -> when (event) { is SafEvent.RequestCreateFile -> { - activeSafActionUserId = event.userId // Retain for CSV export if applicable + activeSafActionUserId = event.userId if (event.actionId == SettingsViewModel.ACTION_ID_BACKUP_DB) { - backupDbLauncher.launch(event.suggestedName) - } else { // Assumption: other CreateFile is CSV export + manualBackupDbLauncher.launch(event.suggestedName) + } else { exportCsvLauncher.launch(event.suggestedName) } } is SafEvent.RequestOpenFile -> { - activeSafActionUserId = event.userId // Retain for CSV import if applicable + activeSafActionUserId = event.userId if (event.actionId == SettingsViewModel.ACTION_ID_RESTORE_DB) { - // For DB Restore, we might expect specific MIME types, - // e.g., "application/octet-stream" or "application/x-sqlite3" for .db, - // or "application/zip" if using ZIPs. - // Using a general type for now: - restoreDbLauncher.launch(arrayOf("*/*")) - } else { // Assumption: other OpenFile is CSV import - val mimeTypes = arrayOf( - "text/csv", - "text/comma-separated-values", - "application/csv", - "text/plain" - ) + // For DB restore, we show a confirmation dialog first. + // The actual launch happens after confirmation. This SAF event is for when that's confirmed. + restoreDbLauncher.launch(arrayOf("*/*")) // Generic MIME type for DB files + } else { + val mimeTypes = arrayOf("text/csv", "text/comma-separated-values", "application/csv", "text/plain") importCsvLauncher.launch(mimeTypes) } } @@ -210,79 +315,20 @@ fun DataManagementSettingsScreen( } } - val regularDataManagementItems = buildList { - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_export_measurements_csv), - icon = Icons.Default.FileDownload, - onClick = { - if (!isAnyOperationLoading) settingsViewModel.startExportProcess() - }, - enabled = users.isNotEmpty() && !isAnyOperationLoading, - isLoading = isLoadingExport - ) - ) - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_import_measurements_csv), - icon = Icons.Default.FileUpload, - onClick = { - if (!isAnyOperationLoading) settingsViewModel.startImportProcess() - }, - enabled = users.isNotEmpty() && !isAnyOperationLoading, - isLoading = isLoadingImport - ) - ) - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_backup_database), - icon = Icons.Default.CloudDownload, - onClick = { - if (!isAnyOperationLoading) settingsViewModel.startDatabaseBackup() - }, - enabled = !isAnyOperationLoading, // Always enabled if no other operation is loading - isLoading = isLoadingBackup - ) - ) - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_restore_database), - icon = Icons.Filled.CloudUpload, - onClick = { - if (!isAnyOperationLoading) showRestoreConfirmationDialog = true - }, - enabled = !isAnyOperationLoading, // Always enabled if no other operation is loading - isLoading = isLoadingRestore - ) - ) + val regularDataManagementItems = remember(users, isAnyOperationLoading, isLoadingExport, isLoadingImport, isLoadingBackup, isLoadingRestore, context) { + buildList { + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_export_measurements_csv), Icons.Default.FileDownload, { if (!isAnyOperationLoading) settingsViewModel.startExportProcess() }, users.isNotEmpty() && !isAnyOperationLoading, isLoading = isLoadingExport)) + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_import_measurements_csv), Icons.Default.FileUpload, { if (!isAnyOperationLoading) settingsViewModel.startImportProcess() }, users.isNotEmpty() && !isAnyOperationLoading, isLoading = isLoadingImport)) + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_backup_database_manual), Icons.Default.CloudDownload, { if (!isAnyOperationLoading) settingsViewModel.startDatabaseBackup() }, !isAnyOperationLoading, isLoading = isLoadingBackup)) + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_restore_database), Icons.Filled.CloudUpload, { if (!isAnyOperationLoading) showRestoreConfirmationDialog = true }, !isAnyOperationLoading, isLoading = isLoadingRestore)) + } } - val destructiveDataManagementItems = buildList { - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_delete_all_measurement_data), - icon = Icons.Default.DeleteForever, - onClick = { - if (!isAnyOperationLoading) settingsViewModel.initiateDeleteAllUserDataProcess() - }, - enabled = users.isNotEmpty() && !isAnyOperationLoading, // Disable if no users or other operation loading - isDestructive = true, - isLoading = isLoadingDeletion - ) - ) - - add( - DataManagementSettingListItem.ActionItem( - label = stringResource(R.string.settings_delete_entire_database), - icon = Icons.Default.WarningAmber, // Or another appropriate icon - onClick = { - if (!isAnyOperationLoading) settingsViewModel.initiateDeleteEntireDatabaseProcess() - }, - enabled = !isAnyOperationLoading, // Always enable if no other operation is loading - isDestructive = true, - isLoading = isLoadingEntireDatabaseDeletion - ) - ) + val destructiveDataManagementItems = remember(users, isAnyOperationLoading, isLoadingDeletion, isLoadingEntireDatabaseDeletion, context) { + buildList { + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_delete_all_measurement_data), Icons.Default.DeleteForever, { if (!isAnyOperationLoading) settingsViewModel.initiateDeleteAllUserDataProcess() }, users.isNotEmpty() && !isAnyOperationLoading, true, isLoadingDeletion)) + add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_delete_entire_database), Icons.Default.WarningAmber, { if (!isAnyOperationLoading) settingsViewModel.initiateDeleteEntireDatabaseProcess() }, !isAnyOperationLoading, true, isLoadingEntireDatabaseDeletion)) + } } LazyColumn( @@ -290,304 +336,325 @@ fun DataManagementSettingsScreen( .fillMaxSize() .padding(horizontal = 16.dp, vertical = 8.dp) ) { - // Regular Actions items(regularDataManagementItems.size) { index -> val item = regularDataManagementItems[index] + SettingsCardItem(item.label, icon = item.icon, onClick = item.onClick, enabled = item.enabled, isDestructive = item.isDestructive, isLoading = item.isLoading) + } + + item { + Spacer(modifier = Modifier.height(24.dp)) + Text(stringResource(R.string.settings_auto_backup_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp)) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(8.dp)) + } + + // 1. Enable/Disable Automatic Backups (Toggle) + item { SettingsCardItem( - label = item.label, - icon = item.icon, - onClick = item.onClick, - enabled = item.enabled, - isDestructive = item.isDestructive, // Will be false here - isLoading = item.isLoading + label = stringResource(R.string.settings_enable_auto_backups), + onClick = { + if (!isAnyOperationLoading) { + val newCheckedState = !autoBackupGloballyEnabled + if (newCheckedState && !isAutoBackupLocationConfigured) { + selectAutoBackupDirectoryLauncher.launch(null) // URI (null) means "pick a new folder" + } else { + coroutineScope.launch { settingsViewModel.setAutoBackupEnabledGlobally(context.applicationContext,newCheckedState) } + } + } + }, + enabled = !isAnyOperationLoading, + customLeadingContent = { + Icon( + Icons.Filled.Schedule, + contentDescription = stringResource(R.string.content_desc_auto_backups_toggle), + tint = if (isAutoBackupEffectivelyEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = { + Switch( + checked = autoBackupGloballyEnabled, + onCheckedChange = { newCheckedState -> + if (!isAnyOperationLoading) { + if (newCheckedState && !isAutoBackupLocationConfigured) { + selectAutoBackupDirectoryLauncher.launch(null) + } else { + coroutineScope.launch { settingsViewModel.setAutoBackupEnabledGlobally(context.applicationContext,newCheckedState) } + } + } + }, + enabled = !isAnyOperationLoading + ) + } ) } + // 2. Backup Location Configuration (only visible if global switch is on) + if (autoBackupGloballyEnabled) { + item { + SettingsCardItem( + label = stringResource(R.string.settings_backup_location_label), + supportingText = currentBackupLocationUserDisplay, + onClick = { + if (!isAnyOperationLoading) { + selectAutoBackupDirectoryLauncher.launch(null) // Allow changing/re-selecting + } + }, + enabled = !isAnyOperationLoading, + customLeadingContent = { Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.content_desc_backup_location_icon)) }, + trailingContent = { + Row(horizontalArrangement = Arrangement.End) { + if (canOpenSelectedBackupLocation && autoBackupLocationUriString != null) { + IconButton( + onClick = { + if (!isAnyOperationLoading) { + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(autoBackupLocationUriString!!.toUri(), "vnd.android.document/directory") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, R.string.settings_backup_location_open_error_no_app, Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, R.string.settings_backup_location_open_error, Toast.LENGTH_SHORT).show() + } + } + }, + enabled = !isAnyOperationLoading + ) { + Icon(Icons.Filled.FolderOpen, contentDescription = stringResource(R.string.content_desc_open_backup_location_icon)) + } + } + IconButton( + onClick = { + if (!isAnyOperationLoading) { + selectAutoBackupDirectoryLauncher.launch(null) + } + }, + enabled = !isAnyOperationLoading + ) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.content_desc_change_backup_location_icon)) + } + } + } + ) + } + } + + // 3. Further Auto-Backup settings (only visible if *effectively* enabled) + if (isAutoBackupEffectivelyEnabled) { + item { + SettingsCardItem( + label = stringResource(R.string.settings_last_backup_status_label), + supportingText = lastBackupStatusText, + onClick = { /* Could show more details or trigger a manual sync if needed */ }, + enabled = !isAnyOperationLoading, + customLeadingContent = { Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.content_desc_backup_status_icon)) } + ) + } + item { + SettingsCardItem( + label = stringResource(R.string.settings_backup_interval_label), + supportingText = selectedBackupIntervalDisplay, + onClick = { if (!isAnyOperationLoading) showBackupIntervalDialog = true }, + enabled = !isAnyOperationLoading, + customLeadingContent = { Icon(Icons.Filled.Schedule, contentDescription = stringResource(R.string.content_desc_backup_interval_icon)) }, + trailingContent = { Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.content_desc_change_interval_icon)) } + ) + } + item { + SettingsCardItem( + label = stringResource(R.string.settings_backup_behavior_label), + supportingText = backupBehaviorSupportingText, + onClick = { if (!isAnyOperationLoading) { + coroutineScope.launch { settingsViewModel.setAutoBackupCreateNewFile(!autoBackupCreateNewFile) } + }}, + enabled = !isAnyOperationLoading, + customLeadingContent = { Icon(Icons.Filled.SwapHoriz, contentDescription = stringResource(R.string.content_desc_backup_behavior_icon)) }, + trailingContent = { + Switch( + checked = autoBackupCreateNewFile, + onCheckedChange = { isChecked -> + if (!isAnyOperationLoading) { + coroutineScope.launch { settingsViewModel.setAutoBackupCreateNewFile(isChecked) } + } + }, + enabled = !isAnyOperationLoading + ) + } + ) + } + } + if (destructiveDataManagementItems.isNotEmpty()) { item { Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.settings_danger_zone), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(bottom = 8.dp) - ) - HorizontalDivider( - thickness = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ) + Text(stringResource(R.string.settings_danger_zone), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(8.dp)) } - items(destructiveDataManagementItems.size) { index -> val item = destructiveDataManagementItems[index] - SettingsCardItem( - label = item.label, - icon = item.icon, - onClick = item.onClick, - enabled = item.enabled, - isDestructive = item.isDestructive, - isLoading = item.isLoading // Pass isLoading to the item - ) + SettingsCardItem(item.label, icon = item.icon, onClick = item.onClick, enabled = item.enabled, isDestructive = item.isDestructive, isLoading = item.isLoading) } } } - // UserSelectionDialog for Export + if (showBackupIntervalDialog) { + val intervalEnumValues = remember { BackupInterval.entries.toList() } + SelectionDialogEnum( + title = stringResource(R.string.dialog_title_select_backup_interval), + options = intervalEnumValues, + selectedOption = autoBackupInterval, + onOptionSelected = { selectedEnumInterval -> + coroutineScope.launch { settingsViewModel.setAutoBackupInterval(context.applicationContext,selectedEnumInterval) } + }, + optionToDisplayName = { it.getDisplayName(context) }, + onDismissRequest = { showBackupIntervalDialog = false } + ) + } + + if (showDeleteEntireDatabaseConfirmationDialog) { + AlertDialog( + onDismissRequest = { if (!isLoadingEntireDatabaseDeletion) settingsViewModel.cancelDeleteEntireDatabaseConfirmation() }, + icon = { Icon(Icons.Filled.WarningAmber, contentDescription = stringResource(R.string.content_desc_warning_icon), tint = MaterialTheme.colorScheme.error) }, + title = { Text(stringResource(R.string.dialog_title_delete_entire_database_confirmation), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) }, + text = { Text(stringResource(R.string.dialog_message_delete_entire_database_confirmation)) }, + confirmButton = { TextButton({ settingsViewModel.confirmDeleteEntireDatabase(context.applicationContext) }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), enabled = !isLoadingEntireDatabaseDeletion) { if (isLoadingEntireDatabaseDeletion) CircularProgressIndicator(Modifier.size(ButtonDefaults.IconSize), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) else Text(stringResource(R.string.button_yes_delete_all)) } }, + dismissButton = { TextButton({ settingsViewModel.cancelDeleteEntireDatabaseConfirmation() }, enabled = !isLoadingEntireDatabaseDeletion) { Text(stringResource(R.string.cancel_button)) } } + ) + } + if (showUserSelectionDialogForExport) { - UserSelectionDialog( - users = users, - onUserSelected = { userId -> settingsViewModel.proceedWithExportForUser(userId) }, - onDismiss = { if (!isLoadingExport) settingsViewModel.cancelUserSelectionForExport() }, - title = stringResource(R.string.dialog_title_export_select_user), - confirmButtonEnabled = !isLoadingExport, - itemClickEnabled = !isLoadingExport - ) + UserSelectionDialog(users, { settingsViewModel.proceedWithExportForUser(it) }, { if (!isLoadingExport) settingsViewModel.cancelUserSelectionForExport() }, stringResource(R.string.dialog_title_export_select_user), !isLoadingExport, !isLoadingExport) } - // UserSelectionDialog for Import if (showUserSelectionDialogForImport) { - UserSelectionDialog( - users = users, - onUserSelected = { userId -> settingsViewModel.proceedWithImportForUser(userId) }, - onDismiss = { if (!isLoadingImport) settingsViewModel.cancelUserSelectionForImport() }, - title = stringResource(R.string.dialog_title_import_select_user), - confirmButtonEnabled = !isLoadingImport, - itemClickEnabled = !isLoadingImport - ) + UserSelectionDialog(users, { settingsViewModel.proceedWithImportForUser(it) }, { if (!isLoadingImport) settingsViewModel.cancelUserSelectionForImport() }, stringResource(R.string.dialog_title_import_select_user), !isLoadingImport, !isLoadingImport) } - // UserSelectionDialog for Delete User Data if (showUserSelectionDialogForDelete) { - UserSelectionDialog( - users = users, - onUserSelected = { userId -> settingsViewModel.proceedWithDeleteForUser(userId) }, - onDismiss = { if (!isLoadingDeletion) settingsViewModel.cancelUserSelectionForDelete() }, - title = stringResource(R.string.dialog_title_delete_select_user), - confirmButtonEnabled = !isLoadingDeletion, - itemClickEnabled = !isLoadingDeletion - ) + UserSelectionDialog(users, { settingsViewModel.proceedWithDeleteForUser(it) }, { if (!isLoadingDeletion) settingsViewModel.cancelUserSelectionForDelete() }, stringResource(R.string.dialog_title_delete_select_user), !isLoadingDeletion, !isLoadingDeletion) } - // Confirmation dialog for deleting a specific user's data (shown AFTER a user is selected) if (showDeleteConfirmationDialog) { - userPendingDeletion?.let { userToDelete -> // Use the user stored in the ViewModel + userPendingDeletion?.let { user -> AlertDialog( onDismissRequest = { if (!isLoadingDeletion) settingsViewModel.cancelDeleteConfirmation() }, + icon = { Icon(Icons.Filled.DeleteForever, contentDescription = stringResource(R.string.content_desc_delete_icon), tint = MaterialTheme.colorScheme.error) }, title = { Text(stringResource(R.string.dialog_title_delete_user_data_confirmation), fontWeight = FontWeight.Bold) }, - text = { - Text( - stringResource(R.string.dialog_message_delete_user_data_confirmation, userToDelete.name), - color = MaterialTheme.colorScheme.error - ) - }, - confirmButton = { - TextButton( - onClick = { - settingsViewModel.confirmActualDeletion() - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - enabled = !isLoadingDeletion - ) { - if (isLoadingDeletion) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) - } else { - Text(stringResource(R.string.button_yes_delete_all)) - } - } - }, - dismissButton = { - TextButton( - onClick = { settingsViewModel.cancelDeleteConfirmation() }, - enabled = !isLoadingDeletion - ) { - Text(stringResource(R.string.cancel_button)) - } - } + text = { Text(stringResource(R.string.dialog_message_delete_user_data_confirmation, user.name)) }, + confirmButton = { TextButton({ settingsViewModel.confirmActualDeletion() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), enabled = !isLoadingDeletion) { if (isLoadingDeletion) CircularProgressIndicator(Modifier.size(ButtonDefaults.IconSize), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) else Text(stringResource(R.string.button_yes_delete_all)) } }, + dismissButton = { TextButton({ settingsViewModel.cancelDeleteConfirmation() }, enabled = !isLoadingDeletion) { Text(stringResource(R.string.cancel_button)) } } ) } } - // Confirmation dialog for deleting the entire database - if (showDeleteEntireDatabaseConfirmationDialog) { - AlertDialog( - onDismissRequest = { - if (!isLoadingEntireDatabaseDeletion) { // Only allow closing if not currently deleting - settingsViewModel.cancelDeleteEntireDatabaseConfirmation() - } - }, - icon = { Icon(Icons.Filled.WarningAmber, contentDescription = stringResource(R.string.content_desc_warning_icon), tint = MaterialTheme.colorScheme.error) }, - title = { - Text(stringResource(R.string.dialog_title_delete_entire_database_confirmation), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) - }, - text = { - Text(stringResource(R.string.dialog_message_delete_entire_database_confirmation)) - }, - confirmButton = { - TextButton( - onClick = { - settingsViewModel.confirmDeleteEntireDatabase(context.applicationContext) - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - enabled = !isLoadingEntireDatabaseDeletion - ) { - if (isLoadingEntireDatabaseDeletion) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) - } else { - Text(stringResource(R.string.button_yes_delete_all)) - } - } - }, - dismissButton = { - TextButton( - onClick = { settingsViewModel.cancelDeleteEntireDatabaseConfirmation() }, - enabled = !isLoadingEntireDatabaseDeletion - ) { - Text(stringResource(R.string.cancel_button)) - } - } - ) - } - - // Confirmation dialog for restoring the database if (showRestoreConfirmationDialog) { AlertDialog( - onDismissRequest = { - if (!isLoadingRestore) showRestoreConfirmationDialog = false // Only dismiss if not loading - }, + onDismissRequest = { if (!isLoadingRestore) showRestoreConfirmationDialog = false }, icon = { Icon(Icons.Filled.CloudUpload, contentDescription = stringResource(R.string.content_desc_restore_icon)) }, - title = { - Text(stringResource(R.string.dialog_title_restore_database_confirmation), fontWeight = FontWeight.Bold) - }, - text = { - Text(stringResource(R.string.dialog_message_restore_database_confirmation)) - }, - confirmButton = { - TextButton( - onClick = { - showRestoreConfirmationDialog = false - settingsViewModel.startDatabaseRestore() // This will trigger the SAF event - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), // Destructive action - enabled = !isLoadingRestore - ) { - if (isLoadingRestore) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) - } else { - Text(stringResource(R.string.button_yes_restore)) - } - } - }, - dismissButton = { - TextButton( - onClick = { - showRestoreConfirmationDialog = false - }, - enabled = !isLoadingRestore - ) { - Text(stringResource(R.string.cancel_button)) - } - } + title = { Text(stringResource(R.string.dialog_title_restore_database_confirmation), fontWeight = FontWeight.Bold) }, + text = { Text(stringResource(R.string.dialog_message_restore_database_confirmation)) }, + confirmButton = { TextButton({ showRestoreConfirmationDialog = false; settingsViewModel.startDatabaseRestore() /* This now triggers SAF event */ }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), enabled = !isLoadingRestore) { if (isLoadingRestore) CircularProgressIndicator(Modifier.size(ButtonDefaults.IconSize), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) else Text(stringResource(R.string.button_yes_restore)) } }, + dismissButton = { TextButton({ showRestoreConfirmationDialog = false }, enabled = !isLoadingRestore) { Text(stringResource(R.string.cancel_button)) } } ) } } -/** - * Composable item for displaying a setting in a card layout. - * It includes a label, an icon (or a loading indicator), and handles click actions. - * - * @param label The text label for the setting. - * @param icon The icon to display for the setting. - * @param onClick The lambda to execute when the item is clicked. - * @param enabled Whether the item is clickable and interactive. Defaults to true. - * @param isDestructive If true, indicates a potentially dangerous action, styled with error colors. Defaults to false. - * @param isLoading If true, shows a loading indicator instead of the icon and disables clicks. Defaults to false. - */ @Composable fun SettingsCardItem( label: String, - icon: ImageVector, + supportingText: String? = null, + icon: ImageVector? = null, onClick: () -> Unit, enabled: Boolean = true, isDestructive: Boolean = false, - isLoading: Boolean = false + isLoading: Boolean = false, + customLeadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null ) { - // Clickability is determined by both 'enabled' and not 'isLoading' val currentClickable = enabled && !isLoading - - val baseTextColor = if (isDestructive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface // Or onBackground / onSurfaceVariant as per your theme - } - - val baseIconColor = if (isDestructive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary // Or onSurfaceVariant etc. depending on design - } - - // Text color adjusted for enabled state (ignoring isLoading for visual disabled state) - val textColor = if (!enabled) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } else { - baseTextColor - } - - // Icon color adjusted for enabled state - val iconColor = if (!enabled) { - baseIconColor.copy(alpha = 0.38f) // Use the base color (primary or error) and reduce alpha - } else { - baseIconColor - } + val baseTextColor = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + val textColor = if (!enabled) baseTextColor.copy(alpha = 0.38f) else baseTextColor + val baseIconColor = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + val iconColorToUse = if (!enabled) baseIconColor.copy(alpha = 0.38f) else baseIconColor Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) - .clickable(enabled = currentClickable, onClick = onClick) // Clickability controlled here + .padding(vertical = 6.dp) // Consistent padding + .clickable(enabled = currentClickable, onClick = onClick) ) { ListItem( - headlineContent = { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, // Consider titleSmall or bodyLarge based on importance - color = textColor - ) - }, - leadingContent = { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp)) { // Box for consistent icon/loader size - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), // Slightly smaller than the box for padding - strokeWidth = 2.dp, - color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - ) - } else { - Icon( - imageVector = icon, - contentDescription = label, // Basic content description - tint = iconColor - ) + headlineContent = { Text(label, style = MaterialTheme.typography.bodyLarge, color = textColor) }, + supportingContent = supportingText?.let { { Text(it, style = MaterialTheme.typography.bodyMedium, color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)) } }, + leadingContent = customLeadingContent ?: icon?.let { + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp)) { // Ensure icon and progress indicator are same size + if (isLoading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary) + else Icon(it, contentDescription = label, tint = iconColorToUse) } } - } - // No trailing content in this design, but can be added if needed. + }, + trailingContent = trailingContent ) } } /** - * Composable dialog for selecting a user from a list. - * - * @param users The list of [User] objects to display for selection. - * @param onUserSelected Lambda called with the selected user's ID. - * @param onDismiss Lambda called when the dialog is dismissed (e.g., by clicking the cancel button or outside the dialog). - * @param title The title of the dialog. - * @param confirmButtonEnabled Controls the enabled state of the dismiss ("Cancel") button. Defaults to true. - * @param itemClickEnabled Controls whether the user list items are clickable. Defaults to true. + * A generic selection dialog for Enums or any list of items + * where each item needs a display name. */ +@Composable +fun SelectionDialogEnum( + title: String, + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + optionToDisplayName: (T) -> String, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup()) { + options.forEach { option -> + val displayName = optionToDisplayName(option) + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (option == selectedOption), + onClick = { + onOptionSelected(option) + onDismissRequest() // Dismiss after selection + } + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (option == selectedOption), + onClick = null // RadioButton is controlled by Row's selectable + ) + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel_button)) + } + } + ) +} + @Composable fun UserSelectionDialog( users: List, @@ -598,49 +665,30 @@ fun UserSelectionDialog( itemClickEnabled: Boolean = true ) { if (users.isEmpty()) { - // If the dialog is shown with no users, dismiss it immediately. - // It's better to prevent opening the dialog if users list is empty (logic in ViewModel). - LaunchedEffect(Unit) { // Ensure onDismiss is called within a composition - onDismiss() - } + LaunchedEffect(Unit) { onDismiss() } return } - AlertDialog( - onDismissRequest = { if (confirmButtonEnabled) onDismiss() }, // Allow dismiss only if not blocked - title = { Text(text = title, style = MaterialTheme.typography.titleLarge) }, // Or headlineSmall + onDismissRequest = { if (confirmButtonEnabled) onDismiss() }, + title = { Text(title, style = MaterialTheme.typography.titleLarge) }, text = { LazyColumn { items(users.size) { index -> val user = users[index] - val textColor = if (itemClickEnabled) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + val textColor = if (itemClickEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) Text( - text = user.name, - style = MaterialTheme.typography.bodyLarge, // Or subtitle1 + user.name, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier .fillMaxWidth() - .clickable(enabled = itemClickEnabled) { // Control item clickability - onUserSelected(user.id) - } + .clickable(enabled = itemClickEnabled) { onUserSelected(user.id) } .padding(vertical = 12.dp), color = textColor ) - if (index < users.size - 1) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // Added vertical padding - } + if (index < users.size - 1) HorizontalDivider(Modifier.padding(vertical = 8.dp)) } } }, - confirmButton = { // In this dialog, the AlertDialog's "confirmButton" acts as our "Cancel" button. - TextButton( - onClick = onDismiss, - enabled = confirmButtonEnabled // Control enabled state of the "Cancel" button - ) { - Text(stringResource(R.string.cancel_button)) - } - } - // No dismissButton is explicitly defined here as the confirmButton serves as "Cancel". - // Tapping outside or back press is handled by onDismissRequest. + confirmButton = { TextButton(onDismiss, enabled = confirmButtonEnabled) { Text(stringResource(R.string.cancel_button)) } } ) } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt index 66a14f37..6bce37bd 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -17,14 +17,23 @@ */ package com.health.openscale.ui.screen.settings +import android.app.Application import android.content.ContentResolver +import android.content.Context import android.net.Uri import androidx.compose.material3.SnackbarDuration import androidx.lifecycle.ViewModel +import androidx.lifecycle.application import androidx.lifecycle.viewModelScope +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import com.github.doyaaaaaken.kotlincsv.dsl.csvReader import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter import com.health.openscale.R +import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType @@ -37,16 +46,19 @@ import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.utils.CalculationUtil import com.health.openscale.core.utils.Converters import com.health.openscale.core.utils.LogManager +import com.health.openscale.core.worker.BackupWorker import com.health.openscale.ui.screen.SharedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -66,6 +78,7 @@ import java.time.temporal.ChronoField import java.time.temporal.TemporalQueries.localDate import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @@ -129,6 +142,22 @@ class SettingsViewModel( private val _isLoadingEntireDatabaseDeletion = MutableStateFlow(false) val isLoadingEntireDatabaseDeletion: StateFlow = _isLoadingEntireDatabaseDeletion.asStateFlow() + val autoBackupEnabledGlobally: StateFlow = userSettingsRepository.autoBackupEnabledGlobally + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val autoBackupLocationUri: StateFlow = userSettingsRepository.autoBackupLocationUri + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val autoBackupInterval: StateFlow = userSettingsRepository.autoBackupInterval + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BackupInterval.WEEKLY) + + val autoBackupCreateNewFile: StateFlow = userSettingsRepository.autoBackupCreateNewFile + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val autoBackupLastSuccessfulTimestamp: StateFlow = userSettingsRepository.autoBackupLastSuccessfulTimestamp + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + companion object { private const val TAG = "SettingsViewModel" const val ACTION_ID_EXPORT_USER_DATA = "export_user_data" @@ -209,6 +238,76 @@ class SettingsViewModel( return defaultLang } + suspend fun setAutoBackupEnabledGlobally(context : Context, enabled: Boolean) { + LogManager.d(TAG, "Setting auto backup globally enabled to: $enabled") + userSettingsRepository.setAutoBackupEnabledGlobally(enabled) + scheduleOrCancelAutoBackupWorker(context.applicationContext) + } + + suspend fun setAutoBackupLocationUri(context : Context, uriString: String?) { + LogManager.d(TAG, "Setting auto backup location URI to: $uriString") + userSettingsRepository.setAutoBackupLocationUri(uriString) + if (uriString != null && !autoBackupEnabledGlobally.value) { + setAutoBackupEnabledGlobally(context, true) + } else if (uriString == null) { + setAutoBackupEnabledGlobally(context,false) + } + } + + suspend fun setAutoBackupInterval(context : Context, interval: BackupInterval) { + LogManager.d(TAG, "Setting auto backup interval to: ${interval.name}") + userSettingsRepository.setAutoBackupInterval(interval) + scheduleOrCancelAutoBackupWorker(context.applicationContext) + } + + suspend fun setAutoBackupCreateNewFile(createNew: Boolean) { + LogManager.d(TAG, "Setting auto backup create new file to: $createNew") + userSettingsRepository.setAutoBackupCreateNewFile(createNew) + } + + private fun scheduleOrCancelAutoBackupWorker(context: Context) { + viewModelScope.launch { + val isEnabled = userSettingsRepository.autoBackupEnabledGlobally.first() + val intervalSetting = userSettingsRepository.autoBackupInterval.first() + val locationUri = userSettingsRepository.autoBackupLocationUri.first() + + val workManager = WorkManager.getInstance(context.applicationContext) + + if (isEnabled && locationUri != null) { + val repeatIntervalMillis = when (intervalSetting) { + BackupInterval.DAILY -> 24 * 60 * 60 * 1000L + BackupInterval.WEEKLY -> 7 * 24 * 60 * 60 * 1000L + BackupInterval.MONTHLY -> 30 * 24 * 60 * 60 * 1000L + } + + val flexMillis = repeatIntervalMillis / 8 + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) + .setRequiresStorageNotLow(true) + .build() + + val backupWorkRequest = PeriodicWorkRequestBuilder( + repeatIntervalMillis, TimeUnit.MILLISECONDS, + flexMillis, TimeUnit.MILLISECONDS + ) + .setConstraints(constraints) + .addTag(BackupWorker.TAG) + .build() + + workManager.enqueueUniquePeriodicWork( + BackupWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + backupWorkRequest + ) + LogManager.i(TAG, "Scheduled auto backup worker with interval: $intervalSetting") + } else { + workManager.cancelUniqueWork(BackupWorker.WORK_NAME) + LogManager.i(TAG, "Cancelled auto backup worker.") + } + } + } + fun performCsvExport(userId: Int, uri: Uri, contentResolver: ContentResolver) { viewModelScope.launch { _isLoadingExport.value = true @@ -865,7 +964,7 @@ class SettingsViewModel( } } - fun performDatabaseRestore(restoreUri: Uri, applicationContext: android.content.Context, contentResolver: ContentResolver) { + fun performDatabaseRestore(restoreUri: Uri, applicationContext: Context, contentResolver: ContentResolver) { viewModelScope.launch { _isLoadingRestore.value = true LogManager.i(TAG, "Performing database restore from URI: $restoreUri") @@ -988,7 +1087,7 @@ class SettingsViewModel( LogManager.d(TAG, "Delete entire database confirmation cancelled.") } - fun confirmDeleteEntireDatabase(applicationContext: android.content.Context) { + fun confirmDeleteEntireDatabase(applicationContext: Context) { viewModelScope.launch { _isLoadingEntireDatabaseDeletion.value = true _showDeleteEntireDatabaseConfirmationDialog.value = 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 d1257a57..43c014ab 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -331,6 +331,44 @@ Gesamte Datenbank löschen Gefahrenzone Unbekannter Fehler + Automatische Sicherungen + Automatische Sicherungen aktivieren + Automatische Sicherungen umschalten + Status der letzten Sicherung + Informationen zum Sicherungsstatus + Sicherungsort + Einstellung Sicherungsort + Sicherungsort öffnen + Sicherungsort ändern + Sicherungsintervall + Einstellung Sicherungsintervall + Sicherungsintervall ändern + Sicherungsverhalten + Einstellung Sicherungsverhalten + Letzte Sicherung: %1$s + Automatische Sicherungen deaktiviert + Immer neue Sicherungsdatei erstellen + Vorhandene Sicherungsdatei überschreiben + Standard: App-spezifischer Ordner + Simuliert: Benutzer hat einen Ordner ausgewählt + Sicherungsintervall auswählen + Sicherungsort nicht konfiguriert + Ort nicht konfiguriert. Automatische Sicherungen pausiert. + Fehler beim Zugriff auf Sicherungsort + Datenbank sichern (Manuell) + Löschen-Symbol + Sicherungsverzeichnis auswählen + Sicherungsort auswählen + Ausgewählter Ordner + Keine Anwendung zum Öffnen des Ordners gefunden. + Sicherungsort konnte nicht geöffnet werden. + Sicherungsort festgelegt auf: %1$s + Ordnerauswahl abgebrochen. Automatisches Backup nicht aktiviert. + Letztes Backup: %1$s + Letztes Backup: Nie + Täglich + Wöchentlich + Monatlich Export: Benutzer auswählen diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 23a7dac4..36fa0310 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -333,6 +333,44 @@ Delete entire database Danger Zone Unknown error + Automatic Backups + Enable automatic backups + Toggle automatic backups + Last backup status + Backup status information + Backup location + Backup location setting + Open backup location + Change backup location + Backup interval + Backup interval setting + Change backup interval + Backup Behavior + Backup behavior setting + Last backup: %1$s + Automatic backups disabled + Always create a new backup file + Overwrite existing backup file + Default: App-specific folder + Simulated: User chose a folder + Select Backup Interval + Backup location not configured + Location not configured. Automatic backups paused. + Error accessing backup location + Backup database (Manual) + Delete icon + Select Backup Directory + Select Backup Location + Selected folder + No application found to open the folder. + Could not open backup location. + Backup location set to: %1$s + Folder selection cancelled. Automatic backup not enabled. + Last backup: %1$s + Last backup: Never + Daily + Weekly + Monthly Export: Select User diff --git a/android_app/gradle/libs.versions.toml b/android_app/gradle/libs.versions.toml index 98d9292a..20f55709 100644 --- a/android_app/gradle/libs.versions.toml +++ b/android_app/gradle/libs.versions.toml @@ -1,17 +1,19 @@ [versions] agp = "8.11.0" kotlin = "2.2.0" -coreKtx = "1.16.0" +coreKtx = "1.17.0" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.9.1" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" -composeBom = "2025.06.01" +composeBom = "2025.08.00" room = "2.7.2" -navigation = "2.9.1" -lifecycle = "2.9.1" +navigation = "2.9.3" +lifecycle = "2.9.2" datastore = "1.1.7" +worker="2.10.3" +documentfile = "1.1.0" composeCharts = "2.1.3" composeReorderable = "2.5.1" compose-material = "1.7.8" @@ -41,6 +43,8 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version = "lifecycle" } +androidx-worker = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "worker" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } compose-charts = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "composeCharts" } compose-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "composeCharts" } compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" }