settings: four named groups, and every sub-screen in its own file
The hub was a flat run of nine rows — Appearance, Task form, Reminders, Storage, Accounts, Language, Licences, Privacy, Report a problem — with nothing putting sync next to storage and nothing separating how the app behaves from what the app is. It is four named groups now, each a connected run with one accent chip colour: Appearance (theme and colour, language), Tasks (task form, reminders), Sync and data (accounts, storage), About (licences, privacy, report a problem). The Appearance row takes the wording that used to be its summary — under a header of the same name the old title said nothing, and that string is already translated everywhere. Two new headers, so two new strings; "Tasks" reuses the key the German and Brazilian catalogues already carry. And the 814-line file held the hub plus AppearanceScreen, TaskFormScreen and RemindersScreen while Storage, Export and the account screens lived in their own files, so where a sub-screen lived was arbitrary. Each of the three is its own file now, the shared chips and about bits are in SettingsCommon, and SettingsScreen keeps the hub, the section enum and the back rules between them.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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<Long?>(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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())) }
|
||||
}
|
||||
@@ -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<Long?>(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<Long?>(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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long?>(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
|
||||
}
|
||||
@@ -244,6 +244,9 @@
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<!-- Hub section headers; settings_section_appearance and settings_section_tasks double as them. -->
|
||||
<string name="settings_section_sync">Sync and data</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_appearance_subtitle">Theme and colour</string>
|
||||
<string name="settings_section_task_form">Task form</string>
|
||||
|
||||
+1
-1
Submodule floret-kit updated: d7b7a542cd...1af24841ae
Reference in New Issue
Block a user