diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt new file mode 100644 index 0000000..0b72f30 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt @@ -0,0 +1,74 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.os.Build +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position + +/** How the app looks: the theme, and whether it takes its colours from the wallpaper. */ +@Composable +internal fun AppearanceScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + var showTheme by remember { mutableStateOf(false) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_theme), + summary = stringResource(themeLabel(state.settings.themeMode)), + position = Position.Top, + onClick = { showTheme = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_dynamic_color), + summary = if (dynamicColorAvailable) { + stringResource(R.string.settings_dynamic_color_hint) + } else { + stringResource(R.string.settings_dynamic_color_unavailable) + }, + position = Position.Bottom, + trailing = { + Switch( + checked = state.settings.dynamicColor && dynamicColorAvailable, + onCheckedChange = viewModel::setDynamicColor, + enabled = dynamicColorAvailable, + ) + }, + onClick = if (dynamicColorAvailable) { + { viewModel.setDynamicColor(!state.settings.dynamicColor) } + } else { + null + }, + ) + } + + if (showTheme) { + OptionPicker( + title = stringResource(R.string.settings_theme), + options = ThemeMode.entries, + selected = state.settings.themeMode, + label = { stringResource(themeLabel(it)) }, + onSelect = viewModel::setThemeMode, + onDismiss = { showTheme = false }, + ) + } +} + +private fun themeLabel(mode: ThemeMode): Int = when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt new file mode 100644 index 0000000..d2bf754 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt @@ -0,0 +1,216 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.Manifest +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker +import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.reminderOverrideFor + +/** Whether due tasks notify at all, how far ahead, and which lists differ. */ +@Composable +internal fun RemindersScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val context = LocalContext.current + val notifLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { /* pref already set; a denial just leaves the OS gate shut */ } + val toggleReminders: (Boolean) -> Unit = { enabled -> + viewModel.setRemindersEnabled(enabled) + if (enabled && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + val canExact = rememberExactAlarmAllowed(context) + val exactAlarmRow = showExactAlarmRow() + val dark = isSystemInDarkTheme() + var showOffset by remember { mutableStateOf(false) } + var perListExpanded by remember { mutableStateOf(false) } + var listPickerId by remember { mutableStateOf(null) } + val lists = state.lists.filter { it.id > 0L } + + CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_reminders), + summary = stringResource(R.string.settings_reminders_hint), + position = Position.Top, + trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) }, + onClick = { toggleReminders(!state.settings.remindersEnabled) }, + ) + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes), + position = if (exactAlarmRow) Position.Middle else Position.Bottom, + onClick = { showOffset = true }, + ) + if (exactAlarmRow) { + GroupedRow( + title = stringResource(R.string.settings_exact_alarms), + summary = stringResource( + if (canExact) R.string.settings_exact_alarms_allowed + else R.string.settings_exact_alarms_blocked, + ), + position = Position.Bottom, + onClick = if (canExact) null else ({ context.openExactAlarmSettings() }), + ) + } + + // Per-list overrides: the whole section folds behind one header. Each + // list may keep, drop, or replace the global default for its tasks. + if (lists.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + // The header and the list rows form one connected run: the header + // opens its bottom (Top) when expanded, and the last list rounds it off. + GroupedRow( + title = stringResource(R.string.settings_list_reminders_title), + summary = stringResource(R.string.settings_list_reminders_hint), + position = if (perListExpanded) Position.Top else Position.Alone, + trailing = { + Icon( + imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { perListExpanded = !perListExpanded }, + ) + AnimatedVisibility( + visible = perListExpanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + Column { + lists.forEachIndexed { index, list -> + GroupedRow( + title = list.name, + summary = listOverrideSummary( + listOverrideChoice(state, list.id), + state.settings.reminderLeadMinutes, + ), + // Every list is mid-run under the header; the last rounds off. + position = if (index == lists.lastIndex) Position.Bottom else Position.Middle, + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + onClick = { listPickerId = list.id }, + ) + } + } + } + } + } + + if (showOffset) { + ReminderLeadPicker( + title = stringResource(R.string.settings_default_reminder), + selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)), + allowInherit = false, + allowNone = false, + onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) }, + onDismiss = { showOffset = false }, + ) + } + listPickerId?.let { id -> + ReminderLeadPicker( + title = state.lists.firstOrNull { it.id == id }?.name + ?: stringResource(R.string.settings_default_reminder), + selected = listOverrideChoice(state, id), + allowInherit = true, + allowNone = true, + onSelect = { viewModel.setListReminderOverride(id, it) }, + onDismiss = { listPickerId = null }, + ) + } +} + +/** The stored override for [listId], as a picker choice (absent → inherit). */ +private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride = + state.settings.perListReminderOverride.reminderOverrideFor(listId) + +/** Row summary for a list: its override, or the inherited global default. */ +@Composable +private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) { + ReminderOverride.Inherit -> + stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault)) + ReminderOverride.None -> stringResource(R.string.reminder_none) + is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first()) +} + +/** + * Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM, + * revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row + * would be a permanent no-op — hide it there. + */ +private fun showExactAlarmRow(): Boolean = + Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2 + +@Composable +private fun rememberExactAlarmAllowed(context: Context): Boolean { + var allowed by remember { + mutableStateOf( + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), + ) + } + OnResume { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() + } + } + return allowed +} + +private fun Context.openExactAlarmSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + runCatching { + startActivity( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt new file mode 100644 index 0000000..67bfdf3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.R + +/** + * Token-based accent for a leading icon chip (container / on-container pair), + * so each accent stays correctly paired across theme, dark mode and dynamic + * colour. One accent per hub section, so a section reads as a colour band + * rather than nine chips picked one at a time. + */ +internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary } + +/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */ +@Composable +internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { + val scheme = MaterialTheme.colorScheme + val (background, iconColor) = when (accent) { + ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant + ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer + ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer + ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer + } + Box( + modifier = Modifier.size(40.dp).clip(CircleShape).background(background), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) + } +} + +@Composable +internal fun AppLogo() { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(colorResource(R.color.ic_launcher_background)), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.settings_about_logo_desc), + modifier = Modifier.requiredSize(108.dp), + ) + } +} + +@Composable +internal fun AppVersionText() { + val context = LocalContext.current + val versionName = remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } + .getOrNull() ?: "—" + } + Text( + text = stringResource(R.string.settings_about_version, versionName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + ) +} + +internal fun openUrl(context: Context, url: String) { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 91dfb01..d879a0b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -1,109 +1,59 @@ package de.jeanlucmakiola.agendula.ui.settings -import android.Manifest -import android.app.AlarmManager -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.Image import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Gavel -import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.rounded.AccountTree -import androidx.compose.material.icons.rounded.Circle -import androidx.compose.material.icons.rounded.Flag -import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.Storage -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable 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.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R -import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen -import de.jeanlucmakiola.agendula.data.prefs.ThemeMode -import de.jeanlucmakiola.agendula.domain.TaskFormField -import de.jeanlucmakiola.agendula.ui.export.ExportScreen -import de.jeanlucmakiola.floret.components.AboutCard -import de.jeanlucmakiola.floret.components.AboutLink -import de.jeanlucmakiola.floret.components.CollapsingScaffold -import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.OptionPicker -import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker -import de.jeanlucmakiola.floret.components.pastelize -import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.identity.collapseExit -import de.jeanlucmakiola.floret.locale.AppLanguage -import de.jeanlucmakiola.floret.identity.expandEnter -import de.jeanlucmakiola.floret.reminders.ReminderOverride -import de.jeanlucmakiola.floret.reminders.reminderOverrideFor -import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.agendula.ui.export.ExportScreen +import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen +import de.jeanlucmakiola.floret.components.AboutCard +import de.jeanlucmakiola.floret.components.AboutLink +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ private enum class SettingsSection { @@ -127,18 +77,14 @@ private enum class SettingsSection { } } -/** - * Token-based accent for a leading icon chip (container / on-container pair), - * so each accent stays correctly paired across theme, dark mode and dynamic - * colour. - */ -private enum class ChipAccent { Neutral, Primary, Tertiary } - /** * Settings, structured (after Calendula) as a category hub with sliding * sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the * grouped-row card system; option lists use the full-screen [OptionPicker]. * A full-screen destination; [onBack] pops it. + * + * Every sub-screen is its own file. This one owns the hub, the section enum and + * the back rules between them, and nothing else. */ @Composable fun SettingsScreen( @@ -241,6 +187,12 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { // Hub // --------------------------------------------------------------------------- +/** + * Four named groups, each a connected run of rows with one accent: what the app + * looks like, what a task looks like, where the tasks live and who syncs them, + * and what the app itself is. The About card stays on top — it is the page's + * identity, not one of the categories. + */ @Composable private fun SettingsHub( onBack: () -> Unit, @@ -271,48 +223,56 @@ private fun SettingsHub( ), ) } - Spacer(Modifier.height(16.dp)) + GroupedSectionHeader(stringResource(R.string.settings_section_appearance)) GroupedRow( - title = stringResource(R.string.settings_section_appearance), - summary = stringResource(R.string.settings_appearance_subtitle), + // The old row's *summary*, promoted: under an "Appearance" header a + // row also called Appearance says nothing, and this wording is + // already translated everywhere. + title = stringResource(R.string.settings_appearance_subtitle), position = Position.Top, - leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) + LanguageRow(position = Position.Bottom, accent = ChipAccent.Primary) + + GroupedSectionHeader(stringResource(R.string.settings_section_tasks)) GroupedRow( title = stringResource(R.string.settings_section_task_form), summary = stringResource(R.string.settings_task_form_subtitle), - position = Position.Middle, + position = Position.Top, leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.TaskForm) }, ) GroupedRow( title = stringResource(R.string.settings_section_reminders), summary = stringResource(R.string.settings_reminders_subtitle), - position = Position.Middle, - leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) + + GroupedSectionHeader(stringResource(R.string.settings_section_sync)) + GroupedRow( + title = stringResource(R.string.settings_section_accounts), + summary = stringResource(R.string.settings_accounts_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Secondary) }, + onClick = { onOpenSection(SettingsSection.Accounts) }, + ) GroupedRow( title = stringResource(R.string.settings_section_storage), summary = stringResource(R.string.settings_storage_subtitle), - position = Position.Middle, - leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) }, + position = Position.Bottom, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, onClick = { onOpenSection(SettingsSection.Storage) }, ) - GroupedRow( - title = stringResource(R.string.settings_section_accounts), - summary = stringResource(R.string.settings_accounts_subtitle), - position = Position.Middle, - leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, - onClick = { onOpenSection(SettingsSection.Accounts) }, - ) - LanguageRow(position = Position.Middle) + + GroupedSectionHeader(stringResource(R.string.settings_section_about)) GroupedRow( title = stringResource(R.string.settings_licences), summary = stringResource(R.string.settings_licences_subtitle), - position = Position.Middle, + position = Position.Top, leading = { CategoryIcon(Icons.Default.Gavel, ChipAccent.Neutral) }, onClick = { onOpenSection(SettingsSection.Licences) }, ) @@ -351,7 +311,7 @@ private fun PrivacyPolicyRow(position: Position) { * not a family primitive. Everything else matches that recipe. */ @Composable -private fun LanguageRow(position: Position) { +private fun LanguageRow(position: Position, accent: ChipAccent) { val context = LocalContext.current // Setting a locale recreates the activity; mirror the choice locally so the // row updates instantly even before the recreation lands. @@ -365,7 +325,7 @@ private fun LanguageRow(position: Position) { title = stringResource(R.string.settings_language), summary = languageLabel(current), position = position, - leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Language, accent) }, onClick = { showDialog = true }, ) @@ -414,401 +374,3 @@ private fun ReportProblemRow(position: Position) { onClick = { openUrl(context, issueUrl) }, ) } - -// --------------------------------------------------------------------------- -// Sub-screens -// --------------------------------------------------------------------------- - -@Composable -private fun AppearanceScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - var showTheme by remember { mutableStateOf(false) } - - CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { - GroupedRow( - title = stringResource(R.string.settings_theme), - summary = stringResource(themeLabel(state.settings.themeMode)), - position = Position.Top, - onClick = { showTheme = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_dynamic_color), - summary = if (dynamicColorAvailable) { - stringResource(R.string.settings_dynamic_color_hint) - } else { - stringResource(R.string.settings_dynamic_color_unavailable) - }, - position = Position.Bottom, - trailing = { - Switch( - checked = state.settings.dynamicColor && dynamicColorAvailable, - onCheckedChange = viewModel::setDynamicColor, - enabled = dynamicColorAvailable, - ) - }, - onClick = if (dynamicColorAvailable) { - { viewModel.setDynamicColor(!state.settings.dynamicColor) } - } else { - null - }, - ) - } - - if (showTheme) { - OptionPicker( - title = stringResource(R.string.settings_theme), - options = ThemeMode.entries, - selected = state.settings.themeMode, - label = { stringResource(themeLabel(it)) }, - onSelect = viewModel::setThemeMode, - onDismiss = { showTheme = false }, - ) - } -} - -@Composable -private fun TaskFormScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - var showDefaultList by remember { mutableStateOf(false) } - - CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { - Text( - text = stringResource(R.string.settings_form_fields_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - val fields = TaskFormField.entries - fields.forEachIndexed { index, field -> - val checked = field in state.settings.defaultEditFields - GroupedRow( - title = stringResource(formFieldLabel(field)), - position = positionOf(index, fields.size), - leading = { - Icon( - imageVector = formFieldIcon(field), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) }) - }, - onClick = { viewModel.setFormFieldDefault(field, !checked) }, - ) - } - - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_default_list), - summary = defaultListLabel(state), - position = Position.Top, - onClick = { showDefaultList = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_add_subtask_row), - summary = stringResource(R.string.settings_add_subtask_row_hint), - position = Position.Middle, - trailing = { - Switch( - checked = state.settings.showAddSubtaskRow, - onCheckedChange = viewModel::setShowAddSubtaskRow, - ) - }, - onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, - ) - GroupedRow( - title = stringResource(R.string.settings_bottom_add_bar), - summary = stringResource(R.string.settings_bottom_add_bar_hint), - position = Position.Bottom, - trailing = { - Switch( - checked = state.settings.bottomAddBar, - onCheckedChange = viewModel::setBottomAddBar, - ) - }, - onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) }, - ) - } - - if (showDefaultList) { - val dark = isSystemInDarkTheme() - // null = first available list; the rest are real list ids. - val options = remember(state.lists) { listOf(null) + state.lists.map { it.id } } - OptionPicker( - title = stringResource(R.string.settings_default_list), - options = options, - selected = state.settings.defaultListId, - label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) }, - leading = { id -> - state.lists.firstOrNull { it.id == id }?.let { list -> - Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) - } - }, - onSelect = { viewModel.setDefaultList(it) }, - onDismiss = { showDefaultList = false }, - ) - } -} - -@Composable -private fun RemindersScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - val context = LocalContext.current - val notifLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { /* pref already set; a denial just leaves the OS gate shut */ } - val toggleReminders: (Boolean) -> Unit = { enabled -> - viewModel.setRemindersEnabled(enabled) - if (enabled && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED - ) { - notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - val canExact = rememberExactAlarmAllowed(context) - val exactAlarmRow = showExactAlarmRow() - val dark = isSystemInDarkTheme() - var showOffset by remember { mutableStateOf(false) } - var perListExpanded by remember { mutableStateOf(false) } - var listPickerId by remember { mutableStateOf(null) } - val lists = state.lists.filter { it.id > 0L } - - CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { - GroupedRow( - title = stringResource(R.string.settings_reminders), - summary = stringResource(R.string.settings_reminders_hint), - position = Position.Top, - trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) }, - onClick = { toggleReminders(!state.settings.remindersEnabled) }, - ) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes), - position = if (exactAlarmRow) Position.Middle else Position.Bottom, - onClick = { showOffset = true }, - ) - if (exactAlarmRow) { - GroupedRow( - title = stringResource(R.string.settings_exact_alarms), - summary = stringResource( - if (canExact) R.string.settings_exact_alarms_allowed - else R.string.settings_exact_alarms_blocked, - ), - position = Position.Bottom, - onClick = if (canExact) null else ({ context.openExactAlarmSettings() }), - ) - } - - // Per-list overrides: the whole section folds behind one header. Each - // list may keep, drop, or replace the global default for its tasks. - if (lists.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - // The header and the list rows form one connected run: the header - // opens its bottom (Top) when expanded, and the last list rounds it off. - GroupedRow( - title = stringResource(R.string.settings_list_reminders_title), - summary = stringResource(R.string.settings_list_reminders_hint), - position = if (perListExpanded) Position.Top else Position.Alone, - trailing = { - Icon( - imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { perListExpanded = !perListExpanded }, - ) - AnimatedVisibility( - visible = perListExpanded, - enter = expandEnter(), - exit = collapseExit(), - ) { - Column { - lists.forEachIndexed { index, list -> - GroupedRow( - title = list.name, - summary = listOverrideSummary( - listOverrideChoice(state, list.id), - state.settings.reminderLeadMinutes, - ), - // Every list is mid-run under the header; the last rounds off. - position = if (index == lists.lastIndex) Position.Bottom else Position.Middle, - leading = { - Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) - }, - onClick = { listPickerId = list.id }, - ) - } - } - } - } - } - - if (showOffset) { - ReminderLeadPicker( - title = stringResource(R.string.settings_default_reminder), - selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)), - allowInherit = false, - allowNone = false, - onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) }, - onDismiss = { showOffset = false }, - ) - } - listPickerId?.let { id -> - ReminderLeadPicker( - title = state.lists.firstOrNull { it.id == id }?.name - ?: stringResource(R.string.settings_default_reminder), - selected = listOverrideChoice(state, id), - allowInherit = true, - allowNone = true, - onSelect = { viewModel.setListReminderOverride(id, it) }, - onDismiss = { listPickerId = null }, - ) - } -} - -/** The stored override for [listId], as a picker choice (absent → inherit). */ -private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride = - state.settings.perListReminderOverride.reminderOverrideFor(listId) - -/** Row summary for a list: its override, or the inherited global default. */ -@Composable -private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) { - ReminderOverride.Inherit -> - stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault)) - ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first()) -} - -// --------------------------------------------------------------------------- -// About + shared building blocks -// --------------------------------------------------------------------------- - -@Composable -private fun AppLogo() { - Box( - modifier = Modifier - .size(72.dp) - .clip(RoundedCornerShape(20.dp)) - .background(colorResource(R.color.ic_launcher_background)), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = stringResource(R.string.settings_about_logo_desc), - modifier = Modifier.requiredSize(108.dp), - ) - } -} - -@Composable -private fun AppVersionText() { - val context = LocalContext.current - val versionName = remember { - runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } - .getOrNull() ?: "—" - } - Text( - text = stringResource(R.string.settings_about_version, versionName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - ) -} - -/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */ -@Composable -private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { - val scheme = MaterialTheme.colorScheme - val (background, iconColor) = when (accent) { - ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant - ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer - ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer - } - Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(background), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) - } -} - -@Composable -private fun defaultListLabel(state: SettingsUiState): String = - state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name - ?: stringResource(R.string.settings_default_list_first) - -private fun openUrl(context: Context, url: String) { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } -} - -private fun themeLabel(mode: ThemeMode): Int = when (mode) { - ThemeMode.SYSTEM -> R.string.settings_theme_system - ThemeMode.LIGHT -> R.string.settings_theme_light - ThemeMode.DARK -> R.string.settings_theme_dark -} - -private fun formFieldLabel(field: TaskFormField): Int = when (field) { - TaskFormField.Description -> R.string.field_description - TaskFormField.Priority -> R.string.edit_priority_label - TaskFormField.Progress -> R.string.edit_progress_label - TaskFormField.Parent -> R.string.edit_parent_label - TaskFormField.Reminder -> R.string.edit_reminder_label -} - -private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) { - TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes - TaskFormField.Priority -> Icons.Rounded.Flag - TaskFormField.Progress -> Icons.Rounded.Percent - TaskFormField.Parent -> Icons.Rounded.AccountTree - TaskFormField.Reminder -> Icons.Default.Notifications -} - -/** - * Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM, - * revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row - * would be a permanent no-op — hide it there. - */ -private fun showExactAlarmRow(): Boolean = - Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2 - -@Composable -private fun rememberExactAlarmAllowed(context: Context): Boolean { - var allowed by remember { - mutableStateOf( - Build.VERSION.SDK_INT < Build.VERSION_CODES.S || - context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), - ) - } - OnResume { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() - } - } - return allowed -} - -private fun Context.openExactAlarmSettings() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - runCatching { - startActivity( - Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()), - ) - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt new file mode 100644 index 0000000..1eb7924 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -0,0 +1,145 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.rounded.AccountTree +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +/** What a new task's edit form starts out as: its fields, its list, and the add affordances. */ +@Composable +internal fun TaskFormScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showDefaultList by remember { mutableStateOf(false) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { + Text( + text = stringResource(R.string.settings_form_fields_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + val fields = TaskFormField.entries + fields.forEachIndexed { index, field -> + val checked = field in state.settings.defaultEditFields + GroupedRow( + title = stringResource(formFieldLabel(field)), + position = positionOf(index, fields.size), + leading = { + Icon( + imageVector = formFieldIcon(field), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) }) + }, + onClick = { viewModel.setFormFieldDefault(field, !checked) }, + ) + } + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_default_list), + summary = defaultListLabel(state), + position = Position.Top, + onClick = { showDefaultList = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_add_subtask_row), + summary = stringResource(R.string.settings_add_subtask_row_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.settings.showAddSubtaskRow, + onCheckedChange = viewModel::setShowAddSubtaskRow, + ) + }, + onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, + ) + GroupedRow( + title = stringResource(R.string.settings_bottom_add_bar), + summary = stringResource(R.string.settings_bottom_add_bar_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.settings.bottomAddBar, + onCheckedChange = viewModel::setBottomAddBar, + ) + }, + onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) }, + ) + } + + if (showDefaultList) { + val dark = isSystemInDarkTheme() + // null = first available list; the rest are real list ids. + val options = remember(state.lists) { listOf(null) + state.lists.map { it.id } } + OptionPicker( + title = stringResource(R.string.settings_default_list), + options = options, + selected = state.settings.defaultListId, + label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) }, + leading = { id -> + state.lists.firstOrNull { it.id == id }?.let { list -> + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + } + }, + onSelect = { viewModel.setDefaultList(it) }, + onDismiss = { showDefaultList = false }, + ) + } +} + +@Composable +private fun defaultListLabel(state: SettingsUiState): String = + state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name + ?: stringResource(R.string.settings_default_list_first) + +private fun formFieldLabel(field: TaskFormField): Int = when (field) { + TaskFormField.Description -> R.string.field_description + TaskFormField.Priority -> R.string.edit_priority_label + TaskFormField.Progress -> R.string.edit_progress_label + TaskFormField.Parent -> R.string.edit_parent_label + TaskFormField.Reminder -> R.string.edit_reminder_label +} + +private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) { + TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes + TaskFormField.Priority -> Icons.Rounded.Flag + TaskFormField.Progress -> Icons.Rounded.Percent + TaskFormField.Parent -> Icons.Rounded.AccountTree + TaskFormField.Reminder -> Icons.Default.Notifications +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9bf11b..1ef670c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -244,6 +244,9 @@ Settings + + Sync and data + About Appearance Theme and colour Task form diff --git a/floret-kit b/floret-kit index d7b7a54..1af2484 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit d7b7a542cd50e278d585cccbf2936b1ab66f799a +Subproject commit 1af24841ae8e670fb608253086f8c702d4f7ba09