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:
@@ -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>
|
@@ -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}'")
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user