diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml index f7235b98..f19df0f1 100644 --- a/android_app/app/src/main/AndroidManifest.xml +++ b/android_app/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 18229f43..ec4db7ff 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -80,6 +80,13 @@ object SettingsPreferenceKeys { val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file") val AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP = longPreferencesKey("auto_backup_last_successful_timestamp") + // --- Reminder Settings --- + val REMINDER_ENABLED = booleanPreferencesKey("reminder_enabled") + val REMINDER_TEXT = stringPreferencesKey("reminder_text") + val REMINDER_HOUR = intPreferencesKey("reminder_hour") + val REMINDER_MINUTE = intPreferencesKey("reminder_minute") + val REMINDER_DAYS = stringSetPreferencesKey("reminder_days") + // 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" @@ -163,6 +170,22 @@ interface SettingsFacade { val autoBackupLastSuccessfulTimestamp: Flow suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long) + // --- Reminder Settings --- + val reminderEnabled: Flow + suspend fun setReminderEnabled(enabled: Boolean) + + val reminderText: Flow + suspend fun setReminderText(text: String) + + val reminderHour: Flow + suspend fun setReminderHour(hour: Int) + + val reminderMinute: Flow + suspend fun setReminderMinute(minute: Int) + + val reminderDays: Flow> + suspend fun setReminderDays(days: Set) + // Generic Settings Accessors /** * Observes a setting with the given key name and default value. @@ -479,6 +502,77 @@ class SettingsFacadeImpl @Inject constructor( saveSetting(SettingsPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name, timestamp) } + // --- Reminder Settings --- + override val reminderEnabled: Flow = observeSetting( + SettingsPreferenceKeys.REMINDER_ENABLED.name, + false + ).catch { exception -> + LogManager.e(TAG, "Error observing reminderEnabled", exception) + emit(false) + } + + override suspend fun setReminderEnabled(enabled: Boolean) { + LogManager.d(TAG, "Setting reminderEnabled to: $enabled") + saveSetting(SettingsPreferenceKeys.REMINDER_ENABLED.name, enabled) + } + + override val reminderText: Flow = observeSetting( + SettingsPreferenceKeys.REMINDER_TEXT.name, + "" + ).catch { exception -> + LogManager.e(TAG, "Error observing reminderText", exception) + emit("") + } + + override suspend fun setReminderText(text: String) { + LogManager.d(TAG, "Setting reminderText to: $text") + saveSetting(SettingsPreferenceKeys.REMINDER_TEXT.name, text) + } + + override val reminderHour: Flow = observeSetting( + SettingsPreferenceKeys.REMINDER_HOUR.name, + 9 + ).catch { exception -> + LogManager.e(TAG, "Error observing reminderHour", exception) + emit(9) + } + + override suspend fun setReminderHour(hour: Int) { + val h = hour.coerceIn(0, 23) + LogManager.d(TAG, "Setting reminderHour to: $h (raw: $hour)") + saveSetting(SettingsPreferenceKeys.REMINDER_HOUR.name, h) + } + + override val reminderMinute: Flow = observeSetting( + SettingsPreferenceKeys.REMINDER_MINUTE.name, + 0 + ).catch { exception -> + LogManager.e(TAG, "Error observing reminderMinute", exception) + emit(0) + } + + override suspend fun setReminderMinute(minute: Int) { + val m = minute.coerceIn(0, 59) + LogManager.d(TAG, "Setting reminderMinute to: $m (raw: $minute)") + saveSetting(SettingsPreferenceKeys.REMINDER_MINUTE.name, m) + } + + override val reminderDays: Flow> = observeSetting( + SettingsPreferenceKeys.REMINDER_DAYS.name, + emptySet() + ).catch { exception -> + LogManager.e(TAG, "Error observing reminderDays", exception) + emit(emptySet()) + } + + override suspend fun setReminderDays(days: Set) { + val safe = days.filter { + runCatching { java.time.DayOfWeek.valueOf(it) }.isSuccess + }.toSet() + LogManager.d(TAG, "Setting reminderDays to: $safe (raw: $days)") + saveSetting(SettingsPreferenceKeys.REMINDER_DAYS.name, safe) + } + @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/usecase/ReminderUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt new file mode 100644 index 00000000..1e497597 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt @@ -0,0 +1,124 @@ +/* + * 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.usecase + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.health.openscale.core.facade.SettingsFacade +import com.health.openscale.core.worker.ReminderWorker +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import java.time.DayOfWeek +import java.time.Duration +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +@Module +@InstallIn(SingletonComponent::class) +object TimeModule { + + @Provides + @Singleton + fun provideClock(): Clock = Clock.systemDefaultZone() +} + +/** + * ReminderUseCase – calculates the next reminder occurrence based on user settings + * and schedules/cancels the worker accordingly. + * + * Keeps domain logic (when) separate from infra details (how via WorkManager). + */ +@Singleton +class ReminderUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val settings: SettingsFacade, + private val clock: Clock +) { + /** Recompute and (re)schedule the next reminder. Cancels if disabled or no days selected. */ + suspend fun rescheduleNext() { + val enabled = runCatching { settings.reminderEnabled.first() }.getOrElse { false } + val days = runCatching { settings.reminderDays.first() }.getOrElse { emptySet() } + if (!enabled || days.isEmpty()) { + cancel() + return + } + + val hour = runCatching { settings.reminderHour.first() }.getOrElse { 9 }.coerceIn(0, 23) + val minute = runCatching { settings.reminderMinute.first() }.getOrElse { 0 }.coerceIn(0, 59) + + val now = ZonedDateTime.now(clock) + val next = computeNext(now, days, hour, minute) + scheduleAt(next) + } + + /** Cancel any scheduled reminder. */ + fun cancel() { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + + private fun scheduleAt(next: ZonedDateTime) { + val delayMs = Duration.between(ZonedDateTime.now(clock), next).toMillis().coerceAtLeast(0) + val req = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + req + ) + } + + /** + * Compute the next ZonedDateTime at which the reminder should trigger. + * @param selectedDayNames Set of DayOfWeek.name strings (e.g., "MONDAY"). + */ + private fun computeNext( + from: ZonedDateTime, + selectedDayNames: Set, + hour: Int, + minute: Int + ): ZonedDateTime { + val days: Set = selectedDayNames.mapNotNull { name -> + runCatching { DayOfWeek.valueOf(name) }.getOrNull() + }.toSet() + + var candidate = from.withHour(hour).withMinute(minute).withSecond(0).withNano(0) + + // If today not allowed or time already passed, move forward day-by-day until allowed + if (candidate.isBefore(from) || candidate.dayOfWeek !in days) { + repeat(7) { + candidate = candidate.plusDays(1) + if (candidate.dayOfWeek in days) return candidate + } + } + return candidate + } + + companion object { + const val WORK_NAME = "daily_reminder_work" + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/worker/BootReceiver.kt b/android_app/app/src/main/java/com/health/openscale/core/worker/BootReceiver.kt new file mode 100644 index 00000000..1351ee6b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/worker/BootReceiver.kt @@ -0,0 +1,53 @@ +/* + * 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.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.health.openscale.core.usecase.ReminderUseCase +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * BootReceiver – ensures reminders are rescheduled after a device reboot. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val appContext = context.applicationContext + val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java) + val reminderUseCase = entryPoint.reminderUseCase() + + CoroutineScope(Dispatchers.Default).launch { + reminderUseCase.rescheduleNext() + } + } + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface BootReceiverEntryPoint { + fun reminderUseCase(): ReminderUseCase +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/worker/ReminderWorker.kt b/android_app/app/src/main/java/com/health/openscale/core/worker/ReminderWorker.kt new file mode 100644 index 00000000..4da4969a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/worker/ReminderWorker.kt @@ -0,0 +1,111 @@ +/* + * 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.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.health.openscale.MainActivity +import com.health.openscale.R +import com.health.openscale.core.facade.SettingsFacade +import com.health.openscale.core.usecase.ReminderUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first + +@HiltWorker +class ReminderWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val settings: SettingsFacade, + private val reminderUseCase: ReminderUseCase +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val enabled = runCatching { settings.reminderEnabled.first() }.getOrElse { false } + if (!enabled) return Result.success() + + val customText = runCatching { settings.reminderText.first() }.getOrElse { "" } + val content = customText.ifBlank { + appContext.getString(R.string.reminder_default_text) + } + + showNotification(content) + + // Schedule next occurrence + runCatching { reminderUseCase.rescheduleNext() } + + return Result.success() + } + + private fun showNotification(content: String) { + // Create channel on O+ + val nm = NotificationManagerCompat.from(appContext) + val channel = NotificationChannel( + CHANNEL_ID, + appContext.getString(R.string.reminder_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + nm.createNotificationChannel(channel) + + // Tap opens app + val intent = Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or (PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, pendingFlags) + + val notification = NotificationCompat.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_beta_foreground) + .setContentTitle(appContext.getString(R.string.reminder_notification_title)) + .setContentText(content) + .setStyle(NotificationCompat.BigTextStyle().bigText(content)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + // Explicit permission check to satisfy Lint and avoid SecurityException on Android 13+ + val canNotify = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } else { + true + } + + if (canNotify) { + NotificationManagerCompat.from(appContext).notify(NOTIFICATION_ID, notification) + } + } + + companion object { + const val CHANNEL_ID = "reminders" + const val NOTIFICATION_ID = 1001 + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt index acd0d738..1fdedb41 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt @@ -17,24 +17,44 @@ */ package com.health.openscale.ui.screen.settings +import android.Manifest import android.app.Activity +import android.app.TimePickerDialog import android.content.ActivityNotFoundException import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -44,6 +64,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -54,17 +76,25 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.data.SupportedLanguage import com.health.openscale.core.utils.LogManager +import com.health.openscale.ui.screen.dialog.TimeInputDialog import com.health.openscale.ui.shared.SharedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.Calendar @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -93,6 +123,53 @@ fun GeneralSettingsScreen( var showLoggingActivationDialog by remember { mutableStateOf(false) } + // Reminder state + val isReminderEnabled by sharedViewModel.reminderEnabled.collectAsState(initial = false) + val reminderText by sharedViewModel.reminderText.collectAsState(initial = "") + val reminderHour by sharedViewModel.reminderHour.collectAsState(initial = 9) + val reminderMinute by sharedViewModel.reminderMinute.collectAsState(initial = 0) + val reminderDays by sharedViewModel.reminderDays.collectAsState(initial = emptySet()) + + var showTimePicker by remember { mutableStateOf(false) } + var expandedDays by remember { mutableStateOf(false) } + + val dayOrder = listOf( + java.time.DayOfWeek.MONDAY to stringResource(R.string.monday_short), + java.time.DayOfWeek.TUESDAY to stringResource(R.string.tuesday_short), + java.time.DayOfWeek.WEDNESDAY to stringResource(R.string.wednesday_short), + java.time.DayOfWeek.THURSDAY to stringResource(R.string.thursday_short), + java.time.DayOfWeek.FRIDAY to stringResource(R.string.friday_short), + java.time.DayOfWeek.SATURDAY to stringResource(R.string.saturday_short), + java.time.DayOfWeek.SUNDAY to stringResource(R.string.sunday_short), + ) + + fun selectedDaysLabel(selected: Set): String { + val labels = dayOrder.filter { selected.contains(it.first.name) }.map { it.second } + return when { + labels.isEmpty() -> "—" + labels.size == 7 -> context.getString(R.string.all) + else -> labels.joinToString(", ") + } + } + + val requestPostNotif = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + scope.launch { + if (granted) { + if (reminderText.isBlank()) { + sharedViewModel.setReminderText(context.getString(R.string.reminder_default_text)) + } + sharedViewModel.setReminderEnabled(true) + sharedViewModel.showSnackbar(context.getString(R.string.reminder_enabled_snackbar)) + settingsViewModel.requestReminderReschedule() + } else { + sharedViewModel.setReminderEnabled(false) + sharedViewModel.showSnackbar(context.getString(R.string.permission_denied)) + } + } + } + val createFileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> @@ -123,7 +200,42 @@ fun GeneralSettingsScreen( AlertDialog( onDismissRequest = { showLoggingActivationDialog = false }, title = { Text(text = stringResource(R.string.enable_file_logging_dialog_title)) }, - text = { Text(stringResource(R.string.enable_file_logging_dialog_message)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp) + ) + Text( + text = stringResource(R.string.enable_file_logging_dialog_message), + style = MaterialTheme.typography.bodyMedium + ) + } + + Row(verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp) + ) + Text( + text = stringResource(R.string.enable_file_logging_dialog_message_warning), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + ) + } + } + }, confirmButton = { TextButton( onClick = { @@ -147,7 +259,10 @@ fun GeneralSettingsScreen( } LaunchedEffect(isFileLoggingEnabled) { - hasLogFile = LogManager.getLogFile()?.exists() == true + val file = LogManager.getLogFile() + if (file != null && file.exists()) { + hasLogFile = true + } } LaunchedEffect(Unit) { @@ -157,132 +272,366 @@ fun GeneralSettingsScreen( Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) { - // --- Language Settings --- - ExposedDropdownMenuBox( - expanded = expandedLanguageMenu, - onExpandedChange = { expandedLanguageMenu = !expandedLanguageMenu }, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = selectedLanguage.nativeDisplayName, - onValueChange = {}, // read-only - readOnly = true, - label = { Text(stringResource(id = R.string.settings_language_label)) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Language, - contentDescription = stringResource(id = R.string.settings_language_label) - ) - }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedLanguageMenu) }, - modifier = Modifier - .menuAnchor(type = MenuAnchorType.PrimaryNotEditable) - .fillMaxWidth() - ) - - ExposedDropdownMenu( - expanded = expandedLanguageMenu, - onDismissRequest = { expandedLanguageMenu = false }, - modifier = Modifier.fillMaxWidth() - ) { - SupportedLanguage.entries.forEach { langEnumEntry -> - DropdownMenuItem( - text = { Text(langEnumEntry.nativeDisplayName) }, - onClick = { - if (currentLanguageCode != langEnumEntry.code) { - scope.launch { - sharedViewModel.setAppLanguageCode(langEnumEntry.code) - } - } - expandedLanguageMenu = false - }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - - // --- Diagnostics --- + // --- Language --- Text( - text = stringResource(R.string.diagnostics_title), + text = stringResource(R.string.settings_language_label), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(top = 24.dp, bottom = 8.dp) + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp) ) HorizontalDivider( thickness = 1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = stringResource(R.string.file_logging_icon_content_description), - modifier = Modifier.padding(end = 16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = stringResource(R.string.file_logging_label), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Switch( - checked = isFileLoggingEnabled, - onCheckedChange = { wantsToEnable -> - if (wantsToEnable) { - showLoggingActivationDialog = true + Column(modifier = Modifier.padding(16.dp)) { + ExposedDropdownMenuBox( + expanded = expandedLanguageMenu, + onExpandedChange = { expandedLanguageMenu = !expandedLanguageMenu }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedLanguage.nativeDisplayName, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(id = R.string.settings_language_label)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Language, + contentDescription = stringResource(id = R.string.settings_language_label) + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedLanguageMenu) + }, + modifier = Modifier + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expandedLanguageMenu, + onDismissRequest = { expandedLanguageMenu = false }, + modifier = Modifier.fillMaxWidth() + ) { + SupportedLanguage.entries.forEach { langEnumEntry -> + DropdownMenuItem( + text = { Text(langEnumEntry.nativeDisplayName) }, + onClick = { + if (currentLanguageCode != langEnumEntry.code) { + scope.launch { + sharedViewModel.setAppLanguageCode(langEnumEntry.code) + } + } + expandedLanguageMenu = false + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + // --- Reminder --- + SettingsSectionTitle(text = stringResource(R.string.settings_reminder_title)) + + SettingsGroup( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + title = stringResource(R.string.settings_reminder_enable_label), + checked = isReminderEnabled, + onCheckedChange = { enabled -> + if (enabled) { + val needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + val notGranted = needsPermission && + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + + if (notGranted) { + requestPostNotif.launch(Manifest.permission.POST_NOTIFICATIONS) } else { scope.launch { - sharedViewModel.setFileLoggingEnabled(false) - LogManager.updateLoggingPreference(false) - sharedViewModel.showSnackbar( - context.getString(R.string.file_logging_disabled_snackbar) + if (reminderText.isBlank()) { + sharedViewModel.setReminderText(context.getString(R.string.reminder_default_text)) + } + sharedViewModel.setReminderEnabled(true) + sharedViewModel.showSnackbar(context.getString(R.string.reminder_enabled_snackbar)) + settingsViewModel.requestReminderReschedule() + } + } + } else { + scope.launch { + sharedViewModel.setReminderEnabled(false) + sharedViewModel.showSnackbar(context.getString(R.string.reminder_disabled_snackbar)) + settingsViewModel.requestReminderReschedule() + } + } + }, + content = { + OutlinedTextField( + value = reminderText, + onValueChange = { newValue -> + scope.launch { + sharedViewModel.setReminderText(newValue) + settingsViewModel.requestReminderReschedule() + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(id = R.string.settings_reminder_text_label)) }, + placeholder = { Text(stringResource(id = R.string.settings_reminder_text_placeholder)) } + ) + + ExposedDropdownMenuBox( + expanded = expandedDays, + onExpandedChange = { expandedDays = !expandedDays }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + OutlinedTextField( + value = selectedDaysLabel(reminderDays), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.settings_reminder_days_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDays) }, + modifier = Modifier + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedDays, + onDismissRequest = { expandedDays = false }, + modifier = Modifier.fillMaxWidth() + ) { + dayOrder.forEach { (day, label) -> + val selected = reminderDays.contains(day.name) + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = selected, onCheckedChange = null) + Text(text = label, modifier = Modifier.padding(start = 8.dp)) + } + }, + onClick = { + val new = reminderDays.toMutableSet().apply { + if (selected) remove(day.name) else add(day.name) + }.toSet() + scope.launch { + sharedViewModel.setReminderDays(new) + settingsViewModel.requestReminderReschedule() + } + }, + modifier = Modifier.fillMaxWidth() ) } } } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.settings_reminder_time_label), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + OutlinedButton(onClick = { showTimePicker = true }) { + val hh = reminderHour.toString().padStart(2, '0') + val mm = reminderMinute.toString().padStart(2, '0') + Text("$hh:$mm") + } + } + + if (showTimePicker) { + val initialTs = remember(reminderHour, reminderMinute) { + Calendar.getInstance().apply { + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + set(Calendar.HOUR_OF_DAY, reminderHour.coerceIn(0, 23)) + set(Calendar.MINUTE, reminderMinute.coerceIn(0, 59)) + }.timeInMillis + } + TimeInputDialog( + title = stringResource(id = R.string.settings_reminder_time_label), + initialTimestamp = initialTs, + measurementIcon = MeasurementTypeIcon.IC_TIME, + iconBackgroundColor = MaterialTheme.colorScheme.primary, + onDismiss = { showTimePicker = false }, + onConfirm = { pickedMillis -> + val cal = Calendar.getInstance().apply { timeInMillis = pickedMillis } + val h = cal.get(Calendar.HOUR_OF_DAY) + val m = cal.get(Calendar.MINUTE) + scope.launch { + sharedViewModel.setReminderHour(h) + sharedViewModel.setReminderMinute(m) + settingsViewModel.requestReminderReschedule() + } + } + ) + } + }) + + // --- Diagnostics --- + SettingsSectionTitle(text = stringResource(R.string.diagnostics_title)) + + SettingsGroup( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = stringResource(R.string.file_logging_icon_content_description), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + title = stringResource(R.string.file_logging_label), + checked = isFileLoggingEnabled, + onCheckedChange = { wantsToEnable -> + if (wantsToEnable) { + showLoggingActivationDialog = true + } else { + scope.launch { + sharedViewModel.setFileLoggingEnabled(false) + LogManager.updateLoggingPreference(false) + sharedViewModel.showSnackbar( + context.getString(R.string.file_logging_disabled_snackbar) + ) + } + } + }, + content = { + }, + persistentContent = { + if (isFileLoggingEnabled || hasLogFile) { + OutlinedButton( + onClick = { + val logFile = LogManager.getLogFile() + if (logFile != null && logFile.exists()) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, logFile.name) + } + try { + createFileLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + scope.launch { + sharedViewModel.showSnackbar( + context.getString(R.string.log_export_no_app_error) + ) + } + LogManager.e("GeneralSettingsScreen", + "Error launching create document intent for export", e) + } + } else { + scope.launch { + sharedViewModel.showSnackbar( + context.getString(R.string.log_export_no_file_to_export) + ) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.export_log_file_button)) + } + } + } + ) + } +} + +@Composable +fun SettingsSectionTitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = modifier.padding(top = 24.dp, bottom = 8.dp) + ) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) +} + +@Composable +fun SettingsGroup( + leadingIcon: @Composable (() -> Unit)? = null, + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + summary: String? = null, + content: @Composable ColumnScope.() -> Unit, + persistentContent: (@Composable ColumnScope.() -> Unit)? = null +) { + val container = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + val borderColor = if (checked) + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + else + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .then( + Modifier + .clip(MaterialTheme.shapes.medium) + .border(1.dp, borderColor, MaterialTheme.shapes.medium) + .background(container) + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .semantics { contentDescription = title } + .clickable { onCheckedChange(!checked) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + leadingIcon?.invoke() + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = if (leadingIcon != null) 12.dp else 0.dp) + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } + + if (!summary.isNullOrBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) ) } - if (isFileLoggingEnabled || hasLogFile) { - OutlinedButton( - onClick = { - val logFile = LogManager.getLogFile() - if (logFile != null && logFile.exists()) { - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "text/plain" - putExtra(Intent.EXTRA_TITLE, logFile.name) - } - try { - createFileLauncher.launch(intent) - } catch (e: ActivityNotFoundException) { - scope.launch { - sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_app_error)) - } - LogManager.e( - "GeneralSettingsScreen", - "Error launching create document intent for export", - e - ) - } - } else { - scope.launch { - sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_file_to_export)) - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.export_log_file_button)) - } + if (checked) { + Spacer(Modifier.height(8.dp)) + content() + } + + if (persistentContent != null) { + if (!checked) Spacer(Modifier.height(8.dp)) + persistentContent() } } } 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 0226d7bb..336cc76d 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 @@ -19,7 +19,9 @@ package com.health.openscale.ui.screen.settings import android.content.ContentResolver import android.net.Uri +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.TimePickerState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.health.openscale.R @@ -30,6 +32,7 @@ import com.health.openscale.core.facade.DataManagementFacade import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.facade.UserFacade import com.health.openscale.core.usecase.ImportReport +import com.health.openscale.core.usecase.ReminderUseCase import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.shared.SnackbarEvent import dagger.hilt.android.lifecycle.HiltViewModel @@ -54,7 +57,8 @@ import kotlinx.coroutines.launch class SettingsViewModel @Inject constructor( private val userFacade: UserFacade, private val dataManagementFacade: DataManagementFacade, - private val measurementFacade: MeasurementFacade + private val measurementFacade: MeasurementFacade, + private val reminderUseCase: ReminderUseCase ) : ViewModel() { companion object { @@ -312,6 +316,10 @@ class SettingsViewModel @Inject constructor( } } + suspend fun requestReminderReschedule() { + reminderUseCase.rescheduleNext() + } + // --- Measurement types (MeasurementFacade) --- fun addMeasurementType(type: MeasurementType) = viewModelScope.launch { try { 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 465d5e44..8680f0ed 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -310,6 +310,27 @@ Allgemeine Einstellungen Sprache + Erinnerung + Erinnerungsfunktion + Erinnerungstext + Erinnerungstext bitte hier einfügen + Uhrzeit + Erinnerung aktiviert + Erinnerung deaktiviert + Zeit zum Wiegen + Erinnerungen + openScale + Tage + Erinnerungsfunktion-Berechtigungen abgelehnt + + Mo + Di + Mi + Do + Fr + Sa + So + Alle Datenpunkte anzeigen @@ -335,6 +356,7 @@ Datei-Protokollierung Datei-Protokollierung aktivieren? Wenn aktiviert, speichert die App detaillierte Protokolle lokal auf Ihrem Gerät. Diese sind nützlich für die Fehlerbehebung. Die Dateien können vertrauliche Informationen enthalten. Teilen Sie sie nur mit vertrauenswürdigen Personen. + Beim Aktivieren wird sofort eine neue Protokollierung gestartet. Dabei wird eine neue Logdatei angelegt und die alte gelöscht. Datei-Protokollierung aktiviert Datei-Protokollierung deaktiviert Protokolldatei erfolgreich exportiert. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 07e7a8f4..4e23ca12 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -312,6 +312,27 @@ General Settings Language + Reminder + Reminder function + Reminder text + Input reminder text here + Time + Reminder enabled + Reminder disabled + Time to weight + Reminders + openScale + Days + Notifications permission denied + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + All Show data points @@ -337,6 +358,7 @@ File Logging Enable File Logging? When enabled, the app will save detailed logs locally on your device. These are useful for troubleshooting. The files may contain sensitive information. Only share them with trusted individuals. + Enabling logging will immediately start a new log session. A new log file will be created and the old one will be deleted. File logging enabled File logging disabled Log file exported successfully.