1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-31 12:01:54 +02:00

Add reminder functionality

This commit is contained in:
oliexdev
2025-08-25 17:36:26 +02:00
parent 7013a41c2d
commit 5e755382e1
9 changed files with 904 additions and 111 deletions

View File

@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<permission
android:name="${applicationId}.READ_WRITE_DATA"
@@ -65,6 +67,14 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
<receiver
android:name=".core.worker.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -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<Long>
suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long)
// --- Reminder Settings ---
val reminderEnabled: Flow<Boolean>
suspend fun setReminderEnabled(enabled: Boolean)
val reminderText: Flow<String>
suspend fun setReminderText(text: String)
val reminderHour: Flow<Int>
suspend fun setReminderHour(hour: Int)
val reminderMinute: Flow<Int>
suspend fun setReminderMinute(minute: Int)
val reminderDays: Flow<Set<String>>
suspend fun setReminderDays(days: Set<String>)
// 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<Boolean> = 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<String> = 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<Int> = 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<Int> = 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<Set<String>> = observeSetting(
SettingsPreferenceKeys.REMINDER_DAYS.name,
emptySet<String>()
).catch { exception ->
LogManager.e(TAG, "Error observing reminderDays", exception)
emit(emptySet())
}
override suspend fun setReminderDays(days: Set<String>) {
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 <T> observeSetting(keyName: String, defaultValue: T): Flow<T> {
LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'")

View File

@@ -0,0 +1,124 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<ReminderWorker>()
.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<String>,
hour: Int,
minute: Int
): ZonedDateTime {
val days: Set<DayOfWeek> = 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"
}
}

View File

@@ -0,0 +1,53 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@@ -0,0 +1,111 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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>): 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()
}
}
}

View File

@@ -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 {

View File

@@ -310,6 +310,27 @@
<!-- Allgemeine Einstellungen -->
<string name="settings_general_title">Allgemeine Einstellungen</string>
<string name="settings_language_label">Sprache</string>
<string name="settings_reminder_title">Erinnerung</string>
<string name="settings_reminder_enable_label">Erinnerungsfunktion</string>
<string name="settings_reminder_text_label">Erinnerungstext</string>
<string name="settings_reminder_text_placeholder">Erinnerungstext bitte hier einfügen</string>
<string name="settings_reminder_time_label">Uhrzeit</string>
<string name="reminder_enabled_snackbar">Erinnerung aktiviert</string>
<string name="reminder_disabled_snackbar">Erinnerung deaktiviert</string>
<string name="reminder_default_text">Zeit zum Wiegen</string>
<string name="reminder_channel_name">Erinnerungen</string>
<string name="reminder_notification_title">openScale</string>
<string name="settings_reminder_days_label">Tage</string>
<string name="permission_denied">Erinnerungsfunktion-Berechtigungen abgelehnt</string>
<string name="monday_short">Mo</string>
<string name="tuesday_short">Di</string>
<string name="wednesday_short">Mi</string>
<string name="thursday_short">Do</string>
<string name="friday_short">Fr</string>
<string name="saturday_short">Sa</string>
<string name="sunday_short">So</string>
<string name="all">Alle</string>
<!-- Diagramm Einstellungen -->
<string name="setting_show_chart_points">Datenpunkte anzeigen</string>
@@ -335,6 +356,7 @@
<string name="file_logging_label">Datei-Protokollierung</string>
<string name="enable_file_logging_dialog_title">Datei-Protokollierung aktivieren?</string>
<string name="enable_file_logging_dialog_message">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.</string>
<string name="enable_file_logging_dialog_message_warning">Beim Aktivieren wird sofort eine neue Protokollierung gestartet. Dabei wird eine neue Logdatei angelegt und die alte gelöscht.</string>
<string name="file_logging_enabled_snackbar">Datei-Protokollierung aktiviert</string>
<string name="file_logging_disabled_snackbar">Datei-Protokollierung deaktiviert</string>
<string name="log_export_success">Protokolldatei erfolgreich exportiert.</string>

View File

@@ -312,6 +312,27 @@
<!-- General Settings Screen -->
<string name="settings_general_title">General Settings</string>
<string name="settings_language_label">Language</string>
<string name="settings_reminder_title">Reminder</string>
<string name="settings_reminder_enable_label">Reminder function</string>
<string name="settings_reminder_text_label">Reminder text</string>
<string name="settings_reminder_text_placeholder">Input reminder text here</string>
<string name="settings_reminder_time_label">Time</string>
<string name="reminder_enabled_snackbar">Reminder enabled</string>
<string name="reminder_disabled_snackbar">Reminder disabled</string>
<string name="reminder_default_text">Time to weight</string>
<string name="reminder_channel_name">Reminders</string>
<string name="reminder_notification_title">openScale</string>
<string name="settings_reminder_days_label">Days</string>
<string name="permission_denied">Notifications permission denied</string>
<string name="monday_short">Mon</string>
<string name="tuesday_short">Tue</string>
<string name="wednesday_short">Wed</string>
<string name="thursday_short">Thu</string>
<string name="friday_short">Fri</string>
<string name="saturday_short">Sat</string>
<string name="sunday_short">Sun</string>
<string name="all">All</string>
<!-- Chart Settings Screen -->
<string name="setting_show_chart_points">Show data points</string>
@@ -337,6 +358,7 @@
<string name="file_logging_label">File Logging</string>
<string name="enable_file_logging_dialog_title">Enable File Logging?</string>
<string name="enable_file_logging_dialog_message">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.</string>
<string name="enable_file_logging_dialog_message_warning">Enabling logging will immediately start a new log session. A new log file will be created and the old one will be deleted.</string>
<string name="file_logging_enabled_snackbar">File logging enabled</string>
<string name="file_logging_disabled_snackbar">File logging disabled</string>
<string name="log_export_success">Log file exported successfully.</string>