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.