mirror of
https://github.com/oliexdev/openScale.git
synced 2025-09-03 05:12:42 +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_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
<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
|
<permission
|
||||||
android:name="${applicationId}.READ_WRITE_DATA"
|
android:name="${applicationId}.READ_WRITE_DATA"
|
||||||
@@ -65,6 +67,14 @@
|
|||||||
android:value="androidx.startup"
|
android:value="androidx.startup"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@@ -80,6 +80,13 @@ object SettingsPreferenceKeys {
|
|||||||
val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file")
|
val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file")
|
||||||
val AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP = longPreferencesKey("auto_backup_last_successful_timestamp")
|
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)
|
// Context strings for screen-specific settings (can be used as prefixes for dynamic keys)
|
||||||
const val OVERVIEW_SCREEN_CONTEXT = "overview_screen"
|
const val OVERVIEW_SCREEN_CONTEXT = "overview_screen"
|
||||||
const val GRAPH_SCREEN_CONTEXT = "graph_screen"
|
const val GRAPH_SCREEN_CONTEXT = "graph_screen"
|
||||||
@@ -163,6 +170,22 @@ interface SettingsFacade {
|
|||||||
val autoBackupLastSuccessfulTimestamp: Flow<Long>
|
val autoBackupLastSuccessfulTimestamp: Flow<Long>
|
||||||
suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: 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
|
// Generic Settings Accessors
|
||||||
/**
|
/**
|
||||||
* Observes a setting with the given key name and default value.
|
* 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)
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T> observeSetting(keyName: String, defaultValue: T): Flow<T> {
|
override fun <T> observeSetting(keyName: String, defaultValue: T): Flow<T> {
|
||||||
LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'")
|
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
|
package com.health.openscale.ui.screen.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.TimePickerDialog
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Description
|
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.Language
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AlertDialog
|
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.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -44,6 +64,8 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -54,17 +76,25 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.health.openscale.R
|
import com.health.openscale.R
|
||||||
|
import com.health.openscale.core.data.MeasurementTypeIcon
|
||||||
import com.health.openscale.core.data.SupportedLanguage
|
import com.health.openscale.core.data.SupportedLanguage
|
||||||
import com.health.openscale.core.utils.LogManager
|
import com.health.openscale.core.utils.LogManager
|
||||||
|
import com.health.openscale.ui.screen.dialog.TimeInputDialog
|
||||||
import com.health.openscale.ui.shared.SharedViewModel
|
import com.health.openscale.ui.shared.SharedViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -93,6 +123,53 @@ fun GeneralSettingsScreen(
|
|||||||
|
|
||||||
var showLoggingActivationDialog by remember { mutableStateOf(false) }
|
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(
|
val createFileLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
@@ -123,7 +200,42 @@ fun GeneralSettingsScreen(
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showLoggingActivationDialog = false },
|
onDismissRequest = { showLoggingActivationDialog = false },
|
||||||
title = { Text(text = stringResource(R.string.enable_file_logging_dialog_title)) },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -147,7 +259,10 @@ fun GeneralSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(isFileLoggingEnabled) {
|
LaunchedEffect(isFileLoggingEnabled) {
|
||||||
hasLogFile = LogManager.getLogFile()?.exists() == true
|
val file = LogManager.getLogFile()
|
||||||
|
if (file != null && file.exists()) {
|
||||||
|
hasLogFile = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -157,132 +272,366 @@ fun GeneralSettingsScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
// --- Language Settings ---
|
// --- Language ---
|
||||||
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 ---
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.diagnostics_title),
|
text = stringResource(R.string.settings_language_label),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp)
|
||||||
.padding(top = 24.dp, bottom = 8.dp)
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
modifier = Modifier
|
ExposedDropdownMenuBox(
|
||||||
.fillMaxWidth()
|
expanded = expandedLanguageMenu,
|
||||||
.padding(top = 24.dp, bottom = 8.dp),
|
onExpandedChange = { expandedLanguageMenu = !expandedLanguageMenu },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.fillMaxWidth()
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
) {
|
||||||
) {
|
OutlinedTextField(
|
||||||
Icon(
|
value = selectedLanguage.nativeDisplayName,
|
||||||
imageVector = Icons.Filled.Description,
|
onValueChange = {},
|
||||||
contentDescription = stringResource(R.string.file_logging_icon_content_description),
|
readOnly = true,
|
||||||
modifier = Modifier.padding(end = 16.dp),
|
label = { Text(stringResource(id = R.string.settings_language_label)) },
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
leadingIcon = {
|
||||||
)
|
Icon(
|
||||||
Text(
|
imageVector = Icons.Filled.Language,
|
||||||
text = stringResource(R.string.file_logging_label),
|
contentDescription = stringResource(id = R.string.settings_language_label)
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
)
|
||||||
modifier = Modifier.weight(1f)
|
},
|
||||||
)
|
trailingIcon = {
|
||||||
Switch(
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedLanguageMenu)
|
||||||
checked = isFileLoggingEnabled,
|
},
|
||||||
onCheckedChange = { wantsToEnable ->
|
modifier = Modifier
|
||||||
if (wantsToEnable) {
|
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
|
||||||
showLoggingActivationDialog = true
|
.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 {
|
} else {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sharedViewModel.setFileLoggingEnabled(false)
|
if (reminderText.isBlank()) {
|
||||||
LogManager.updateLoggingPreference(false)
|
sharedViewModel.setReminderText(context.getString(R.string.reminder_default_text))
|
||||||
sharedViewModel.showSnackbar(
|
}
|
||||||
context.getString(R.string.file_logging_disabled_snackbar)
|
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) {
|
if (checked) {
|
||||||
OutlinedButton(
|
Spacer(Modifier.height(8.dp))
|
||||||
onClick = {
|
content()
|
||||||
val logFile = LogManager.getLogFile()
|
}
|
||||||
if (logFile != null && logFile.exists()) {
|
|
||||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
if (persistentContent != null) {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
if (!checked) Spacer(Modifier.height(8.dp))
|
||||||
type = "text/plain"
|
persistentContent()
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -19,7 +19,9 @@ package com.health.openscale.ui.screen.settings
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.TimePickerState
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.health.openscale.R
|
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.MeasurementFacade
|
||||||
import com.health.openscale.core.facade.UserFacade
|
import com.health.openscale.core.facade.UserFacade
|
||||||
import com.health.openscale.core.usecase.ImportReport
|
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.core.utils.LogManager
|
||||||
import com.health.openscale.ui.shared.SnackbarEvent
|
import com.health.openscale.ui.shared.SnackbarEvent
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -54,7 +57,8 @@ import kotlinx.coroutines.launch
|
|||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val userFacade: UserFacade,
|
private val userFacade: UserFacade,
|
||||||
private val dataManagementFacade: DataManagementFacade,
|
private val dataManagementFacade: DataManagementFacade,
|
||||||
private val measurementFacade: MeasurementFacade
|
private val measurementFacade: MeasurementFacade,
|
||||||
|
private val reminderUseCase: ReminderUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -312,6 +316,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun requestReminderReschedule() {
|
||||||
|
reminderUseCase.rescheduleNext()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Measurement types (MeasurementFacade) ---
|
// --- Measurement types (MeasurementFacade) ---
|
||||||
fun addMeasurementType(type: MeasurementType) = viewModelScope.launch {
|
fun addMeasurementType(type: MeasurementType) = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
@@ -310,6 +310,27 @@
|
|||||||
<!-- Allgemeine Einstellungen -->
|
<!-- Allgemeine Einstellungen -->
|
||||||
<string name="settings_general_title">Allgemeine Einstellungen</string>
|
<string name="settings_general_title">Allgemeine Einstellungen</string>
|
||||||
<string name="settings_language_label">Sprache</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 -->
|
<!-- Diagramm Einstellungen -->
|
||||||
<string name="setting_show_chart_points">Datenpunkte anzeigen</string>
|
<string name="setting_show_chart_points">Datenpunkte anzeigen</string>
|
||||||
@@ -335,6 +356,7 @@
|
|||||||
<string name="file_logging_label">Datei-Protokollierung</string>
|
<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_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">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_enabled_snackbar">Datei-Protokollierung aktiviert</string>
|
||||||
<string name="file_logging_disabled_snackbar">Datei-Protokollierung deaktiviert</string>
|
<string name="file_logging_disabled_snackbar">Datei-Protokollierung deaktiviert</string>
|
||||||
<string name="log_export_success">Protokolldatei erfolgreich exportiert.</string>
|
<string name="log_export_success">Protokolldatei erfolgreich exportiert.</string>
|
||||||
|
@@ -312,6 +312,27 @@
|
|||||||
<!-- General Settings Screen -->
|
<!-- General Settings Screen -->
|
||||||
<string name="settings_general_title">General Settings</string>
|
<string name="settings_general_title">General Settings</string>
|
||||||
<string name="settings_language_label">Language</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 -->
|
<!-- Chart Settings Screen -->
|
||||||
<string name="setting_show_chart_points">Show data points</string>
|
<string name="setting_show_chart_points">Show data points</string>
|
||||||
@@ -337,6 +358,7 @@
|
|||||||
<string name="file_logging_label">File Logging</string>
|
<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_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">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_enabled_snackbar">File logging enabled</string>
|
||||||
<string name="file_logging_disabled_snackbar">File logging disabled</string>
|
<string name="file_logging_disabled_snackbar">File logging disabled</string>
|
||||||
<string name="log_export_success">Log file exported successfully.</string>
|
<string name="log_export_success">Log file exported successfully.</string>
|
||||||
|
Reference in New Issue
Block a user