Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/107
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||||
|
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
|
||||||
|
* instead of as words.
|
||||||
|
*
|
||||||
|
* A recurrence rule is the one place in the editor where a correct-sounding
|
||||||
|
* phrase can still mean something the user didn't intend — "every 2 weeks on
|
||||||
|
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
|
||||||
|
* themselves. This is a *preview*, deliberately kept to the shapes the picker
|
||||||
|
* can build; the provider remains the authority on what actually gets stored
|
||||||
|
* and expanded.
|
||||||
|
*
|
||||||
|
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
|
||||||
|
* engine implements):
|
||||||
|
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
|
||||||
|
* rather than sliding to the month's last day: the 31st recurring monthly
|
||||||
|
* lands only in 31-day months, 29 February yearly only in leap years.
|
||||||
|
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
|
||||||
|
* with the week beginning on **Monday** — the RFC's default WKST, and the one
|
||||||
|
* that applies here because [toRRule] never writes a WKST part. The
|
||||||
|
* display-only "first day of week" preference deliberately doesn't enter into
|
||||||
|
* it, or the preview would disagree with the stored rule.
|
||||||
|
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
|
||||||
|
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
|
||||||
|
*
|
||||||
|
* Returns fewer than [limit] dates when the series ends first, and an empty
|
||||||
|
* list when the rule yields nothing at all (an UNTIL before the start).
|
||||||
|
*/
|
||||||
|
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||||
|
if (limit <= 0) return emptyList()
|
||||||
|
val until = (end as? RecurrenceEnd.Until)?.date
|
||||||
|
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||||
|
val wanted = minOf(limit, maxCount)
|
||||||
|
if (wanted <= 0) return emptyList()
|
||||||
|
|
||||||
|
val result = mutableListOf<LocalDate>()
|
||||||
|
var period = 0
|
||||||
|
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
|
||||||
|
// all fall before the start) must not stall the walk, so the cap is on
|
||||||
|
// periods examined rather than on dates found.
|
||||||
|
while (result.size < wanted && period < MAX_PERIODS) {
|
||||||
|
for (date in occurrencesInPeriod(period, start)) {
|
||||||
|
if (date < start) continue
|
||||||
|
if (until != null && date > until) return result
|
||||||
|
result += date
|
||||||
|
if (result.size == wanted) return result
|
||||||
|
}
|
||||||
|
period++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||||
|
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||||
|
when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||||
|
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||||
|
RecurrenceFreq.Monthly -> {
|
||||||
|
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||||
|
// plus() clamps the day into the shorter month; the rule instead
|
||||||
|
// skips such a period, so a clamped date means "not this month".
|
||||||
|
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||||
|
}
|
||||||
|
RecurrenceFreq.Yearly ->
|
||||||
|
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One weekly block: every picked weekday inside the week that begins
|
||||||
|
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||||
|
* no picks the rule simply repeats the start's weekday.
|
||||||
|
*/
|
||||||
|
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||||
|
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||||
|
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||||
|
DAYS_PER_WEEK
|
||||||
|
val weekStart = start
|
||||||
|
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||||
|
.plus(period * interval, DateTimeUnit.WEEK)
|
||||||
|
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||||
|
weekStart.plus(
|
||||||
|
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a run of [occurrences] starting at [start] leaves its starting year —
|
||||||
|
* i.e. whether showing them without a year would be ambiguous.
|
||||||
|
*
|
||||||
|
* Day and month alone are enough for a rule that stays inside one year, and
|
||||||
|
* dropping the year keeps the line short. They are actively misleading
|
||||||
|
* otherwise: a yearly rule reads as the same date three times over, and a
|
||||||
|
* monthly one rolling past December gives no hint that it has.
|
||||||
|
*/
|
||||||
|
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||||
|
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||||
|
|
||||||
|
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||||
|
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||||
|
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||||
|
|
||||||
|
private const val DAYS_PER_WEEK = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repetitions to examine before giving up. Generous enough for the
|
||||||
|
* sparsest rule the picker can build (29 February yearly with a 999-year
|
||||||
|
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
|
||||||
|
* rule whose occurrences all fall outside its own UNTIL can't spin.
|
||||||
|
*/
|
||||||
|
private const val MAX_PERIODS = 2_000
|
||||||
@@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -148,6 +149,11 @@ fun CalendarHost(
|
|||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Backup & restore (#69) — like the manager, driven by the calendar list
|
||||||
|
// rather than by preferences, so it is hoisted here instead of living as a
|
||||||
|
// Settings sub-section. Reached from Settings and from the manager.
|
||||||
|
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
@@ -210,6 +216,7 @@ fun CalendarHost(
|
|||||||
fun dismissCoveringOverlays() {
|
fun dismissCoveringOverlays() {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
|
showBackup = false
|
||||||
detailKey = null
|
detailKey = null
|
||||||
editKey = null
|
editKey = null
|
||||||
importUri = null
|
importUri = null
|
||||||
@@ -293,8 +300,8 @@ fun CalendarHost(
|
|||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
// so back there falls through to the system and exits the app.
|
// so back there falls through to the system and exits the app.
|
||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
@@ -449,6 +456,7 @@ fun CalendarHost(
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
onOpenBackup = { showBackup = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,12 +497,24 @@ fun CalendarHost(
|
|||||||
) {
|
) {
|
||||||
CalendarsScreen(
|
CalendarsScreen(
|
||||||
onBack = { showCalendars = false },
|
onBack = { showCalendars = false },
|
||||||
// The manager opens the import too (restore from backup), and
|
onOpenBackup = { showBackup = true },
|
||||||
// that way round it has to step aside: declared above the import
|
)
|
||||||
// overlays, it would otherwise cover the screen it just asked
|
}
|
||||||
// for. Closing it hands the user back to whatever opened the
|
|
||||||
// manager once the import is done.
|
// Backup & restore — over the manager, since the manager links into it.
|
||||||
onImport = { importUri = it; importForceMany = true; showCalendars = false },
|
AnimatedVisibility(
|
||||||
|
visible = showBackup,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
BackupScreen(
|
||||||
|
onBack = { showBackup = false },
|
||||||
|
// Restoring runs the normal .ics import, and that way round this
|
||||||
|
// screen has to step aside: declared above the import overlays,
|
||||||
|
// it would otherwise cover the screen it just asked for. Closing
|
||||||
|
// it hands the user back to whatever opened Backup once the
|
||||||
|
// import is done.
|
||||||
|
onImport = { importUri = it; importForceMany = true; showBackup = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ fun AgendaScreen(
|
|||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -198,6 +199,7 @@ fun AgendaScreen(
|
|||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
description = stringResource(R.string.agenda_range_override_hint),
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
selected = successState?.range ?: AgendaRange.Month,
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
|
weekStart = weekStart,
|
||||||
onSelect = viewModel::setRangeOverride,
|
onSelect = viewModel::setRangeOverride,
|
||||||
onDismiss = { showRangePicker = false },
|
onDismiss = { showRangePicker = false },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
|
|||||||
settingsPrefs.agendaShowRangeBar,
|
settingsPrefs.agendaShowRangeBar,
|
||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
/**
|
||||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||||
|
* because the range picker resolves each option to real dates, which needs
|
||||||
|
* the same week start the window is built from.
|
||||||
|
*/
|
||||||
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> =
|
val state: StateFlow<AgendaUiState> =
|
||||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||||
AgendaParams(
|
AgendaParams(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
range = override ?: settings.range,
|
range = override ?: settings.range,
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||||
|
// mimes depending on the source app (our own export uses text/calendar; others
|
||||||
|
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||||
|
// than hide valid backups behind an over-tight filter.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
|
||||||
|
* export is their only safety net — that made it a data-safety feature hiding in
|
||||||
|
* the calendar manager, where nobody went looking. It now has its own Settings
|
||||||
|
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
|
||||||
|
* same calendar list).
|
||||||
|
*
|
||||||
|
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||||
|
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BackupScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Export covers the user's own local calendars (managed special-dates
|
||||||
|
// mirrors don't count — they are rebuilt from contacts). Restore can target
|
||||||
|
// any calendar the import picker would offer, so its availability is broader.
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into. This launcher exports everything
|
||||||
|
// eligible (null); the per-calendar selector owns its own launcher.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// SAF "open document" picker for restoring events from a .ics file. The
|
||||||
|
// picked Uri is handed up to the host, which runs it through the same import
|
||||||
|
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||||
|
// write grant so background runs can keep writing to it.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_backup),
|
||||||
|
onBack = onBack,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
|
||||||
|
if (exportable.isNotEmpty()) {
|
||||||
|
// One connected card: the one-time export on top, restore under it,
|
||||||
|
// then automatic backup (and its folder/interval rows when on).
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
// With more than one exportable calendar, let the user choose
|
||||||
|
// which to include; a single one exports straight away.
|
||||||
|
if (exportable.size == 1) {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up (no writable local calendar), but events can
|
||||||
|
// still be restored into a writable calendar — offer restore on its
|
||||||
|
// own so it isn't hidden behind export eligibility.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Seed once with everything selected and hold it across recomposition and
|
||||||
|
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||||
|
// it would silently reset the user's de-selections whenever the provider
|
||||||
|
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||||
|
// — the data layer intersects the chosen set with the eligible calendars.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
@@ -4,9 +4,6 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -26,26 +23,22 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Backup
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -67,34 +60,25 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
||||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||||
@@ -109,50 +93,38 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
|
||||||
// mimes depending on the source app (our own export uses text/calendar; others
|
|
||||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
|
||||||
// than hide valid backups behind an over-tight filter.
|
|
||||||
private val RESTORE_MIME_TYPES = arrayOf(
|
|
||||||
"text/calendar",
|
|
||||||
"application/octet-stream",
|
|
||||||
"text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
* and lists synced calendars read-only with a per-account "manage in the source
|
* and lists synced calendars read-only with a per-account "manage in the source
|
||||||
* app" deep-link — the app never touches a synced calendar's server. A
|
* app" deep-link — the app never touches a synced calendar's server.
|
||||||
* full-screen destination; [onBack] pops it.
|
*
|
||||||
|
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||||
|
* screen only points at it, because the two are looked for separately: "which
|
||||||
|
* calendars do I have" versus "keep a copy of them". A full-screen destination;
|
||||||
|
* [onBack] pops it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (android.net.Uri) -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -190,14 +162,7 @@ fun CalendarsScreen(
|
|||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
onOpenBackup = onOpenBackup,
|
||||||
onExportBackup = viewModel::exportBackup,
|
|
||||||
onImport = onImport,
|
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
|
||||||
autoBackup = autoBackup,
|
|
||||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
|
||||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
|
||||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
@@ -213,14 +178,7 @@ private fun CalendarsList(
|
|||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
onOpenBackup: () -> Unit,
|
||||||
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
|
||||||
onImport: (android.net.Uri) -> Unit,
|
|
||||||
onConsumeBackupResult: () -> Unit,
|
|
||||||
autoBackup: AutoBackupUiState,
|
|
||||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
|
||||||
onSetAutoBackupInterval: (Long) -> Unit,
|
|
||||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
@@ -242,47 +200,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
|
||||||
// to the VM to stream the .ics into. This launcher exports everything
|
|
||||||
// eligible (null); the per-calendar selector owns its own launcher.
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri -> uri?.let { onExportBackup(it, null) } }
|
|
||||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// SAF "open document" picker for restoring events from a .ics file. The
|
|
||||||
// picked Uri is handed up to the host, which runs it through the same import
|
|
||||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
|
||||||
val openBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
) { uri -> uri?.let(onImport) }
|
|
||||||
|
|
||||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
|
||||||
// write grant so background runs can keep writing to it.
|
|
||||||
val pickFolder = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
|
||||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
|
||||||
var showInterval by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
|
||||||
LaunchedEffect(backupResult) {
|
|
||||||
when (val r = backupResult) {
|
|
||||||
is BackupResult.Success -> {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.resources.getQuantityString(
|
|
||||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
onConsumeBackupResult()
|
|
||||||
}
|
|
||||||
BackupResult.Failure -> {
|
|
||||||
snackbarHostState.showSnackbar(backupFailedText)
|
|
||||||
onConsumeBackupResult()
|
|
||||||
}
|
|
||||||
null -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -335,82 +252,24 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Backup lives in its own Settings entry now (#69) — this row is the
|
||||||
// safety net. Offered only when there is something exportable: the user's
|
// pointer, so someone looking at their local calendars still finds the
|
||||||
// own local calendars (managed special-dates mirrors don't count).
|
// way to keep a copy of them.
|
||||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
Spacer(Modifier.height(16.dp))
|
||||||
// Restore/import can target any calendar the import picker would offer
|
GroupedRow(
|
||||||
// (local or synced), so its availability is broader than export's.
|
title = stringResource(R.string.settings_section_backup),
|
||||||
val canImport = (local + synced).any { it.isEventTarget }
|
summary = stringResource(R.string.settings_backup_subtitle),
|
||||||
if (exportable.isNotEmpty()) {
|
position = Position.Alone,
|
||||||
Spacer(Modifier.height(16.dp))
|
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
trailing = {
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
Icon(
|
||||||
// One connected card: the one-time export on top, then automatic
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
// backup (and its folder/interval rows when on).
|
contentDescription = null,
|
||||||
GroupedRow(
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
|
||||||
position = Position.Top,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
|
||||||
onClick = {
|
|
||||||
// With more than one exportable calendar, let the user choose
|
|
||||||
// which to include; a single one exports straight away.
|
|
||||||
if (exportable.size == 1) {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
} else {
|
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = {
|
|
||||||
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
)
|
||||||
GroupedRow(
|
},
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
onClick = onOpenBackup,
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
)
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showInterval = true },
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up (no writable local calendar), but events can
|
|
||||||
// still be restored into a writable calendar — offer restore on its
|
|
||||||
// own so it isn't hidden behind export eligibility.
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -486,93 +345,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = onSetAutoBackupInterval,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExportPicker) {
|
|
||||||
ExportCalendarPicker(
|
|
||||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
|
||||||
onExport = onExportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
|
||||||
* the picked file with the chosen calendar ids.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ExportCalendarPicker(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// Seed once with everything selected and hold it across recomposition and
|
|
||||||
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
|
||||||
// it would silently reset the user's de-selections whenever the provider
|
|
||||||
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
|
||||||
// — the data layer intersects the chosen set with the eligible calendars.
|
|
||||||
var selected by rememberSaveable(
|
|
||||||
stateSaver = listSaver(
|
|
||||||
save = { it.toList() },
|
|
||||||
restore = { it.toSet() },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
mutableStateOf(calendars.map { it.id }.toSet())
|
|
||||||
}
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
onExport(uri, selected)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.calendars_export_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
) {
|
|
||||||
HintText(stringResource(R.string.calendars_export_hint))
|
|
||||||
calendars.forEachIndexed { index, calendar ->
|
|
||||||
val isSelected = calendar.id in selected
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = calendar.description,
|
|
||||||
position = positionOf(index, calendars.size),
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
},
|
|
||||||
enabled = selected.isNotEmpty(),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.calendars_export_action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -958,113 +730,31 @@ private fun CalendarGroupMenu(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
internal fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
// The cards' own edge, so header and group share a left margin.
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HintText(text: String) {
|
internal fun HintText(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
|
||||||
@Composable
|
|
||||||
private fun rememberFolderName(uriString: String?): String? {
|
|
||||||
val context = LocalContext.current
|
|
||||||
return remember(uriString) {
|
|
||||||
uriString?.let {
|
|
||||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
|
||||||
@Composable
|
|
||||||
private fun backupIntervalLabel(minutes: Long): String {
|
|
||||||
val duration = when {
|
|
||||||
minutes % MINUTES_PER_WEEK == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
|
||||||
minutes % MINUTES_PER_DAY == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
|
||||||
minutes % 60L == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
|
||||||
else ->
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
|
||||||
}
|
|
||||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
|
||||||
@Composable
|
|
||||||
private fun backupStatusText(status: BackupStatus): String {
|
|
||||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
|
||||||
val relative = DateUtils.getRelativeTimeSpanString(
|
|
||||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
).toString()
|
|
||||||
return if (status.lastSuccess) {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
|
||||||
@Composable
|
|
||||||
private fun BackupIntervalDialog(
|
|
||||||
currentMinutes: Long,
|
|
||||||
onConfirm: (Long) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
|
||||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
|
||||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
|
||||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
|
||||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
|
||||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
|
||||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
|
||||||
onDismiss()
|
|
||||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MINUTES_PER_DAY = 1_440L
|
|
||||||
private const val MINUTES_PER_WEEK = 10_080L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
|
|||||||
@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
@@ -48,6 +58,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
* back.
|
* back.
|
||||||
|
*
|
||||||
|
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
|
||||||
|
* all-day pickers, where the lead time alone doesn't say when anything happens:
|
||||||
|
* the hour comes from the separate "show all-day reminders at" setting, so
|
||||||
|
* "1 day before" is only half the answer until the row spells out the time.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -57,6 +72,7 @@ fun ReminderDefaultPicker(
|
|||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (ReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// authoritative while the picker is open, so quick successive toggles compose
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
@@ -132,6 +148,7 @@ fun ReminderDefaultPicker(
|
|||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderLeadTimeLabel(minute),
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
summary = leadTimeSummary?.invoke(minute),
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, rowCount),
|
||||||
selected = checked,
|
selected = checked,
|
||||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
@@ -196,14 +213,15 @@ private fun CustomReminderEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||||
|
* on the same left margin as the rows themselves. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +231,19 @@ internal fun PickerDescription(text: String) {
|
|||||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
|
*
|
||||||
|
* Every option carries the dates it resolves to today as its summary, computed
|
||||||
|
* through the same [dayCount] the agenda windows by (hence [weekStart], which
|
||||||
|
* "This week" depends on). Without it the two groups are near-indistinguishable
|
||||||
|
* — "This week" and "Next 7 days" name different windows in the same words, and
|
||||||
|
* only the concrete span tells them apart.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaRangePicker(
|
fun AgendaRangePicker(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
selected: AgendaRange,
|
selected: AgendaRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
onSelect: (AgendaRange) -> Unit,
|
onSelect: (AgendaRange) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -232,10 +257,21 @@ fun AgendaRangePicker(
|
|||||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val locale = currentLocale()
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
// The window each option would open right now — the agenda's own end-day
|
||||||
|
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
|
||||||
|
val windowSummary: (AgendaRange) -> String = { range ->
|
||||||
|
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||||
|
agendaRangeWindowSummary(range, today, end, locale)
|
||||||
|
}
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = agendaRangeLabel(option),
|
title = agendaRangeLabel(option),
|
||||||
|
summary = windowSummary(option),
|
||||||
position = position,
|
position = position,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
@@ -270,6 +306,9 @@ fun AgendaRangePicker(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.agenda_range_custom)
|
stringResource(R.string.agenda_range_custom)
|
||||||
},
|
},
|
||||||
|
// Only a chosen custom window has dates to show; an unset one has no
|
||||||
|
// day count yet, so the row stays single-line until it does.
|
||||||
|
summary = if (customSelected) windowSummary(selected) else null,
|
||||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
selected = customSelected,
|
||||||
trailing = if (customSelected) {
|
trailing = if (customSelected) {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
|
|||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||||
|
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -98,6 +103,43 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||||
|
* the recurrence picker.
|
||||||
|
*
|
||||||
|
* The phrase [recurrenceText] renders says what the rule *is*; this says what
|
||||||
|
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
|
||||||
|
* rule with weekday picks gets wrong in people's heads. It expands the rule
|
||||||
|
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
|
||||||
|
* its limits: only the shapes the picker itself can build.
|
||||||
|
*
|
||||||
|
* A rule that yields nothing at all (an end date before the start) says so
|
||||||
|
* rather than showing an empty list, since that is a mistake worth catching
|
||||||
|
* before saving.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun nextOccurrencesText(
|
||||||
|
rule: SimpleRecurrence,
|
||||||
|
// Spelled out: in this file, the bare LocalDate is java.time's.
|
||||||
|
start: kotlinx.datetime.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||||
|
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||||
|
// Years only once they carry information — a yearly rule is otherwise the
|
||||||
|
// same date repeated, and a monthly one crossing New Year hides that it did.
|
||||||
|
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||||
|
val formatter = localizedDateFormatter(locale, pattern)
|
||||||
|
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||||
|
return stringResource(
|
||||||
|
R.string.event_edit_recurrence_next,
|
||||||
|
ListFormatter.getInstance(locale).format(formatted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||||
|
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -1172,7 +1173,7 @@ private fun EventEditContent(
|
|||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDay = form.start.date.dayOfWeek,
|
startDate = form.start.date,
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
@@ -1340,15 +1341,23 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
* and an end condition — only that step needs an OK button. A rule the
|
* and an end condition — only that step needs an OK button. A rule the
|
||||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||||
* user picks something here.
|
* user picks something here.
|
||||||
|
*
|
||||||
|
* Both steps show the rule as *dates* as well as words: every preset row
|
||||||
|
* carries the next few occurrences it would produce from [startDate], and the
|
||||||
|
* custom step repeats that under its live read-out. It is where a rule most
|
||||||
|
* easily means something other than it sounds like — "monthly" on the 31st
|
||||||
|
* skips February, an every-other-week rule lands a fortnight from the start's
|
||||||
|
* own week — and only the dates say so.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDay: DayOfWeek,
|
startDate: LocalDate,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val startDay = startDate.dayOfWeek
|
||||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||||
@@ -1400,16 +1409,20 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||||
}
|
}
|
||||||
val customResult: String? = if (interval != null && customEnd != null) {
|
// Kept as the rule object, not just its RRULE text: the read-out renders the
|
||||||
|
// string, the date list expands the rule, and both must describe the one
|
||||||
|
// thing OK would save.
|
||||||
|
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
||||||
SimpleRecurrence(
|
SimpleRecurrence(
|
||||||
freq = freq,
|
freq = freq,
|
||||||
interval = interval,
|
interval = interval,
|
||||||
end = customEnd,
|
end = customEnd,
|
||||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||||
).toRRule()
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
val customResult: String? = customRule?.toRRule()
|
||||||
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_recurrence),
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
@@ -1440,6 +1453,7 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
|
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
@@ -1491,6 +1505,18 @@ private fun RecurrencePickerDialog(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The same rule as dates. Rendered even while the form is
|
||||||
|
// incomplete (as an empty line) so the controls below don't
|
||||||
|
// shift as it comes and goes.
|
||||||
|
Text(
|
||||||
|
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
// How often: an interval amount plus a frequency segmented row —
|
// How often: an interval amount plus a frequency segmented row —
|
||||||
// all four units on show, no unit hidden behind a dropdown.
|
// all four units on show, no unit hidden behind a dropdown.
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
@@ -2007,6 +2033,11 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
|||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
|
*
|
||||||
|
* Every level carries a line saying who this affects, because the four words
|
||||||
|
* alone don't: visibility is about what *other people on a shared calendar*
|
||||||
|
* see, which is exactly the part the label leaves out — and "Confidential" has
|
||||||
|
* no meaning at all until you know the server decides what it does with it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun VisibilityPickerDialog(
|
private fun VisibilityPickerDialog(
|
||||||
@@ -2019,6 +2050,7 @@ private fun VisibilityPickerDialog(
|
|||||||
options = AccessLevel.entries.toList(),
|
options = AccessLevel.entries.toList(),
|
||||||
selected = selected,
|
selected = selected,
|
||||||
label = { stringResource(accessLevelLabel(it)) },
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
|
summary = { stringResource(accessLevelSummary(it)) },
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
@@ -2111,6 +2143,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What each level means for people the calendar is shared with. */
|
||||||
|
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||||
|
AccessLevel.Default -> R.string.event_access_default_summary
|
||||||
|
AccessLevel.Public -> R.string.event_access_public_summary
|
||||||
|
AccessLevel.Private -> R.string.event_access_private_summary
|
||||||
|
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
||||||
|
}
|
||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
|
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.Color
|
||||||
|
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.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appearance: how the app itself looks — theme and colour, the two typeface
|
||||||
|
* roles, and the launcher name. Anything that only changes how a *calendar view*
|
||||||
|
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
|
||||||
|
* and the widget-only settings in [WidgetsScreen] (#69).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AppearanceScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.fontImportFailed.collect {
|
||||||
|
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Theme & colour
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
summary = themeLabel(state.themeMode),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTheme = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
|
// Says what it does when on; below Android 12 that is replaced by
|
||||||
|
// the reason the switch is dead.
|
||||||
|
summary = if (state.dynamicColorAvailable) {
|
||||||
|
stringResource(R.string.settings_dynamic_color_summary)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
|
},
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dynamicColor,
|
||||||
|
onCheckedChange = viewModel::setDynamicColor,
|
||||||
|
enabled = state.dynamicColorAvailable,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = if (state.dynamicColorAvailable) {
|
||||||
|
{ viewModel.setDynamicColor(!state.dynamicColor) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_soften_colors),
|
||||||
|
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.softenColors,
|
||||||
|
onCheckedChange = viewModel::setSoftenColors,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Fonts — the two Material typeface roles, each independently choosable
|
||||||
|
// (issue #19). Headings = brand (display/headline); body = plain
|
||||||
|
// (title/body/label). Both default to the system typeface.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
summary = fontLabel(fonts.brand),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showBrandFont = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
summary = fontLabel(fonts.plain),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showPlainFont = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||||
|
// (issue #44). Own group: it's a launcher/system concern, not app styling.
|
||||||
|
// A sub-page chooser (not a switch), matching the app's other "choose one"
|
||||||
|
// settings and leaving room for more names later.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Show both names as launcher-mark previews so the user sees what
|
||||||
|
// they'd switch to, not just the current state. Tapping applies
|
||||||
|
// immediately and highlights — the picker stays open so the change is
|
||||||
|
// visible; back exits.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_name_summary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherName.entries.forEach { option ->
|
||||||
|
AppNameOptionCard(
|
||||||
|
name = option,
|
||||||
|
selected = launcherName == option,
|
||||||
|
onClick = { viewModel.setLauncherName(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showTheme) {
|
||||||
|
// No preview here on purpose: picking a theme repaints the app itself,
|
||||||
|
// which is a better demonstration than any thumbnail. What the list
|
||||||
|
// *can't* show is which way "Follow the system" currently falls, so that
|
||||||
|
// one option says so.
|
||||||
|
val systemDark = isSystemInDarkTheme()
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = ThemeMode.entries,
|
||||||
|
selected = state.themeMode,
|
||||||
|
label = { themeLabel(it) },
|
||||||
|
summary = { mode ->
|
||||||
|
if (mode == ThemeMode.SYSTEM) {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_theme_system_summary,
|
||||||
|
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelect = viewModel::setThemeMode,
|
||||||
|
onDismiss = { showTheme = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showBrandFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
role = FontRole.BRAND,
|
||||||
|
selected = fonts.brand,
|
||||||
|
stamp = fonts.brandStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||||
|
onDismiss = { showBrandFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPlainFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
role = FontRole.PLAIN,
|
||||||
|
selected = fonts.plain,
|
||||||
|
stamp = fonts.plainStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||||
|
onDismiss = { showPlainFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
|
when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
|
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||||
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The summary label for a stored font token (issue #19). */
|
||||||
|
@Composable
|
||||||
|
private fun fontLabel(token: String): String = when (token) {
|
||||||
|
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
||||||
|
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
||||||
|
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
||||||
|
?: stringResource(R.string.settings_font_system)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The display name for a launcher-label choice (issue #44). */
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.CALENDULA -> R.string.app_name
|
||||||
|
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types offered to the document picker so it lists only font files. Covers
|
||||||
|
* the modern `font/` types plus the legacy `application/` font aliases some
|
||||||
|
* providers still report. Anything that slips through is still validated by
|
||||||
|
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
||||||
|
*/
|
||||||
|
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||||
|
"font/ttf",
|
||||||
|
"font/otf",
|
||||||
|
"font/sfnt",
|
||||||
|
"font/collection",
|
||||||
|
"application/x-font-ttf",
|
||||||
|
"application/x-font-otf",
|
||||||
|
"application/font-sfnt",
|
||||||
|
"application/vnd.ms-opentype",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||||
|
* font (previewed in its own face), and "Choose file…" which opens the system
|
||||||
|
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||||
|
* a file is validated and imported by the caller, switching to the custom font
|
||||||
|
* on success.
|
||||||
|
*
|
||||||
|
* Above the rows sits a specimen of the *current* choice, set in the type role
|
||||||
|
* this picker governs — headline for [FontRole.BRAND], body for
|
||||||
|
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
|
||||||
|
* line at the real size says whether it works for the role, and it is the one
|
||||||
|
* thing that makes the two font settings distinguishable from each other.
|
||||||
|
*
|
||||||
|
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
|
||||||
|
* the specimen re-renders instead, which is the whole point of the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontPicker(
|
||||||
|
title: String,
|
||||||
|
role: FontRole,
|
||||||
|
selected: String,
|
||||||
|
stamp: Int,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri ->
|
||||||
|
// Stay open on a successful import too: the specimen switches to the
|
||||||
|
// imported face, which is the only look the user gets before committing.
|
||||||
|
if (uri != null) onImport(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System default + the bundled fonts + the "Choose file…" row.
|
||||||
|
val rowCount = BundledFont.entries.size + 2
|
||||||
|
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||||
|
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
|
||||||
|
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
|
||||||
|
// refreshes the preview while plain recompositions reuse the cached family.
|
||||||
|
val customPreview = remember(role, isCustom, stamp) {
|
||||||
|
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||||
|
}
|
||||||
|
// The face the specimen is set in: the same resolution the theme performs,
|
||||||
|
// so what is shown here is what the app will use.
|
||||||
|
val selectedFamily = remember(role, selected, stamp) {
|
||||||
|
resolveFontFamily(selected, role, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
FontSpecimen(role = role, family = selectedFamily)
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(R.string.settings_font_system),
|
||||||
|
preview = FontFamily.Default,
|
||||||
|
selected = selected == FONT_SYSTEM_TOKEN,
|
||||||
|
position = positionOf(0, rowCount),
|
||||||
|
// Applies straight away and stays open — the specimen above is the
|
||||||
|
// answer to "what does this one look like".
|
||||||
|
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
||||||
|
)
|
||||||
|
BundledFont.entries.forEachIndexed { index, font ->
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(font.labelRes),
|
||||||
|
preview = font.family,
|
||||||
|
selected = selected == font.token,
|
||||||
|
position = positionOf(index + 1, rowCount),
|
||||||
|
onClick = { onSelect(font.token) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FontOptionRow(
|
||||||
|
label = if (isCustom) {
|
||||||
|
stringResource(R.string.settings_font_custom_selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_font_choose_file)
|
||||||
|
},
|
||||||
|
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||||
|
preview = customPreview,
|
||||||
|
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||||
|
selected = isCustom,
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specimen of [family] set in the type role [role] governs: a headline for
|
||||||
|
* the brand role, a paragraph for the plain one — the same styles the app draws
|
||||||
|
* with, only the family swapped, so nothing here can flatter a face the app
|
||||||
|
* won't reproduce. A null [family] is the system typeface, i.e. the Material
|
||||||
|
* default the styles already carry.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||||
|
val isBrand = role == FontRole.BRAND
|
||||||
|
val style = if (isBrand) {
|
||||||
|
MaterialTheme.typography.headlineMedium
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.bodyLarge
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
|
||||||
|
),
|
||||||
|
style = style.copy(fontFamily = family ?: style.fontFamily),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
// Two lines up front so switching between a wide and a narrow face
|
||||||
|
// doesn't shuffle the option list up and down under it.
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||||
|
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||||
|
* to preview), and a check when it's the current selection.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontOptionRow(
|
||||||
|
label: String,
|
||||||
|
preview: FontFamily?,
|
||||||
|
selected: Boolean,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
leadingIcon: ImageVector? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = label,
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
leading = {
|
||||||
|
if (preview != null) {
|
||||||
|
Text(
|
||||||
|
text = "Ag",
|
||||||
|
fontFamily = preview,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
} else if (leadingIcon != null) {
|
||||||
|
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailing = if (selected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||||
|
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||||
|
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||||
|
* is the same for both — only the label changes — so the card previews exactly
|
||||||
|
* what the home screen will read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppNameOptionCard(
|
||||||
|
name: LauncherName,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val borderColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||||
|
// onboarding BrandHero) so it renders identically everywhere.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherNameLabel(name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
/** New event form: which fields it opens with, and how it behaves. */
|
||||||
|
@Composable
|
||||||
|
internal fun EventFormScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
val fields = EventFormField.entries
|
||||||
|
fields.forEachIndexed { index, field ->
|
||||||
|
val checked = field in state.defaultFormFields
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
|
position = positionOf(index, fields.size),
|
||||||
|
// Same icon the field carries in the new-event form, so a toggle
|
||||||
|
// is easy to match to the field it controls.
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = eventFormFieldIcon(field),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||||
|
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||||
|
// set the time/calendar first without the keyboard in the way.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_autofocus_title),
|
||||||
|
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Keyboard,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.autofocusEventTitle,
|
||||||
|
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_color_unsupported),
|
||||||
|
summary = stringResource(R.string.settings_color_unsupported_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.allowColorOnUnsupportedCalendars,
|
||||||
|
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setAllowColorOnUnsupportedCalendars(
|
||||||
|
!state.allowColorOnUnsupportedCalendars,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
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.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
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.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NotificationsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenSpecialDates: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
|
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||||
|
viewModel.setRemindersEnabled(enabled)
|
||||||
|
val needsPermission = enabled &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
if (needsPermission) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
|
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reminders),
|
||||||
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
|
},
|
||||||
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAllDayReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_allday_reminder_time),
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_time_hint,
|
||||||
|
settingsTimeOfDay(state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery reliability + snooze: both are global reminder-delivery
|
||||||
|
// settings, so they sit with the defaults above rather than below the
|
||||||
|
// long per-calendar list. Reliability is a soft, optional battery-
|
||||||
|
// optimisation exemption (system-settings deep-link, no special
|
||||||
|
// permission); shown as live status, reversible at any time.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reliable_delivery),
|
||||||
|
summary = if (batteryExempt) {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
|
},
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: the whole section folds behind one header to
|
||||||
|
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||||
|
// expandable card that may keep, drop, or replace the global default —
|
||||||
|
// separately for timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = calendarSectionExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
// A contact special-dates calendar owns its reminders in
|
||||||
|
// its own section — link there instead of an override.
|
||||||
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onOpenSpecialDates,
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list
|
||||||
|
// of three (the card + the timed and all-day rows).
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
SnoozeDurationPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
presets = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { snoozeDurationLabel(it) },
|
||||||
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
presets = REMINDER_PRESETS,
|
||||||
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||||
|
onDismiss = { showDefaultReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||||
|
onDismiss = { showAllDayReminder = false },
|
||||||
|
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminderTime) {
|
||||||
|
TimePickerAlert(
|
||||||
|
initial = LocalTime(
|
||||||
|
state.allDayReminderTimeMinutes / 60,
|
||||||
|
state.allDayReminderTimeMinutes % 60,
|
||||||
|
),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
||||||
|
showAllDayReminderTime = false
|
||||||
|
},
|
||||||
|
onDismiss = { showAllDayReminderTime = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
overrideDialog?.let { target ->
|
||||||
|
val map = if (target.isAllDay) {
|
||||||
|
state.perCalendarAllDayReminderOverride
|
||||||
|
} else {
|
||||||
|
state.perCalendarReminderOverride
|
||||||
|
}
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(
|
||||||
|
if (target.isAllDay) {
|
||||||
|
R.string.settings_default_reminder_allday
|
||||||
|
} else {
|
||||||
|
R.string.settings_default_reminder
|
||||||
|
},
|
||||||
|
),
|
||||||
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
|
selected = map.reminderOverrideFor(target.calendarId),
|
||||||
|
allowInherit = true,
|
||||||
|
onSelect = {
|
||||||
|
if (target.isAllDay) {
|
||||||
|
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||||
|
} else {
|
||||||
|
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { overrideDialog = null },
|
||||||
|
leadTimeSummary = if (target.isAllDay) {
|
||||||
|
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
|
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||||
|
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||||
|
|
||||||
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
|
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||||
|
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun calendarOverrideSummary(
|
||||||
|
choice: ReminderOverride,
|
||||||
|
globalDefault: List<Int>,
|
||||||
|
): String = when (choice) {
|
||||||
|
ReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||||
|
@Composable
|
||||||
|
private fun snoozeDurationLabel(minutes: Int): String =
|
||||||
|
if (minutes % 60 == 0) {
|
||||||
|
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
|
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||||
|
* settings without needing to leave and re-enter the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
exempt = isIgnoringBatteryOptimizations(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
return exempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return power.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the user straight to Calendula's exemption: the direct
|
||||||
|
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||||
|
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||||
|
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||||
|
*/
|
||||||
|
private fun openBatteryOptimizationSettings(context: Context) {
|
||||||
|
val direct = Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
"package:${context.packageName}".toUri(),
|
||||||
|
)
|
||||||
|
if (runCatching { context.startActivity(direct) }.isFailure) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "past events" chooser for the Agenda (#69 follow-up).
|
||||||
|
*
|
||||||
|
* "Show / dim / hide" is the case where the word cannot carry the choice —
|
||||||
|
* *how far* does dimmed fade, and what is left behind when finished events are
|
||||||
|
* hidden? So a stand-in agenda day sits above the options and re-renders as you
|
||||||
|
* pick one, using the very rows the Agenda draws: dimming is
|
||||||
|
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
|
||||||
|
* preview cannot claim an effect the screen doesn't have.
|
||||||
|
*
|
||||||
|
* Like the Month style and App name pickers, selecting applies immediately and
|
||||||
|
* leaves the picker open: closing would hide the thing the screen is for. Back
|
||||||
|
* exits.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PastEventsPicker(
|
||||||
|
selected: PastEventDisplay,
|
||||||
|
onSelect: (PastEventDisplay) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = PastEventDisplay.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "past-events-preview",
|
||||||
|
) { shown ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
PastEventsPreview(mode = shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PickerDescription(stringResource(R.string.settings_past_events_hint))
|
||||||
|
options.forEachIndexed { index, mode ->
|
||||||
|
val isSelected = mode == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(pastEventDisplayLabelRes(mode)),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
// Applies straight away; the preview above is the confirmation.
|
||||||
|
onClick = { onSelect(mode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
||||||
|
* already finished. The times are fixed rather than derived from the clock, so
|
||||||
|
* the preview reads the same at any hour — what varies is only what [mode] does
|
||||||
|
* to the two finished rows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val titles = listOf(
|
||||||
|
stringResource(R.string.settings_past_events_sample_morning),
|
||||||
|
stringResource(R.string.settings_past_events_sample_midday),
|
||||||
|
stringResource(R.string.settings_past_events_sample_evening),
|
||||||
|
)
|
||||||
|
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
||||||
|
// Hiding drops the finished rows outright; showing and dimming keep them,
|
||||||
|
// and only dimming fades them — the same three-way split AgendaContent makes.
|
||||||
|
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds()
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// rows' own clickables see them, and keep it out of the reading order —
|
||||||
|
// the options below already say what is selected.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { },
|
||||||
|
) {
|
||||||
|
AgendaDayHeader(date = today, today = today, onOpenDay = {})
|
||||||
|
visible.forEachIndexed { index, row ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = row.event,
|
||||||
|
day = today,
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, visible.size),
|
||||||
|
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stand-in row plus whether it counts as finished. */
|
||||||
|
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
|
||||||
|
* one still to come.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose — that is what the provider hands out for an
|
||||||
|
* event, so a theme token here would misrepresent what the agenda renders.
|
||||||
|
*/
|
||||||
|
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
|
||||||
|
listOf(
|
||||||
|
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
|
||||||
|
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
|
||||||
|
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleRow(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
startHour: Int,
|
||||||
|
endHour: Int,
|
||||||
|
color: Int,
|
||||||
|
hasPassed: Boolean,
|
||||||
|
): SampleRow = SampleRow(
|
||||||
|
event = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = day.atTime(startHour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(endHour, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
),
|
||||||
|
hasPassed = hasPassed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Room for the day header plus its three two-line rows, so hiding shortens the
|
||||||
|
* list inside a box that keeps its height instead of the page jumping a row. */
|
||||||
|
private val PREVIEW_HEIGHT = 288.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
|
||||||
|
* sub-screen owns whatever only it uses; anything two of them need lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||||
|
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
|
||||||
|
* to theme, dark mode and dynamic colour.
|
||||||
|
*
|
||||||
|
* The chips are a scanning aid, so the rule is **no two rows in one group share
|
||||||
|
* an accent** — a group in a single colour carries no more information than no
|
||||||
|
* colour at all. [Neutral] is not a fourth colour to rotate through but a
|
||||||
|
* deliberate step back, for rows that are reference or last-resort rather than
|
||||||
|
* somewhere you routinely go.
|
||||||
|
*/
|
||||||
|
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading circular icon chip. Colours come from the M3 scheme via a container /
|
||||||
|
* on-container token pair, so each accent stays correctly paired across theme,
|
||||||
|
* dark mode and dynamic colour.
|
||||||
|
*/
|
||||||
|
@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(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconColor,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||||
|
*
|
||||||
|
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
|
||||||
|
* under it and the group it names all share one left margin.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsHint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openUrl(context: Context, url: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||||
|
@Composable
|
||||||
|
internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||||
|
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||||
|
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||||
|
* before midnight" reminder on an all-day event is rarely what's wanted. Shared
|
||||||
|
* with the contact special-dates calendars, whose events are all all-day.
|
||||||
|
*/
|
||||||
|
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same
|
||||||
|
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
|
||||||
|
* settings can't disagree with the times shown everywhere else.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
|
||||||
|
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second line for an all-day lead-time row: the clock time it fires at.
|
||||||
|
*
|
||||||
|
* An all-day occurrence has no time of its own, so a reminder's stored offset
|
||||||
|
* only decides *which day* it belongs to — the hour always comes from the
|
||||||
|
* global "show all-day reminders at" setting (see
|
||||||
|
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
|
||||||
|
* every row is what makes "At time of event" readable on an all-day default at
|
||||||
|
* all: it means that day at 09:00, not midnight.
|
||||||
|
*
|
||||||
|
* Shared by the notification defaults, the per-calendar overrides and the
|
||||||
|
* contact special-dates calendars — all three read the same one setting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
|
||||||
|
val summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_fires_at,
|
||||||
|
settingsTimeOfDay(allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return { summary }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contact special dates (issue #15)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SpecialDatesScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||||
|
// The all-day reminder hour lives in the general settings state; the reminder
|
||||||
|
// picker below names it, since that is when these reminders actually fire.
|
||||||
|
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||||
|
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||||
|
// banner after the permission was re-granted).
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val requestOrEnable: () -> Unit = {
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
viewModel.setSpecialDatesEnabled(true)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmDisableAll by remember { mutableStateOf(false) }
|
||||||
|
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Paused banner: the permission was revoked after enabling.
|
||||||
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||||
|
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_enable),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.enabled,
|
||||||
|
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.enabled) {
|
||||||
|
// Per-type card: the toggle, and while on, its title format and its
|
||||||
|
// calendar-wide reminder.
|
||||||
|
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||||
|
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
||||||
|
val on = type in state.types
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(specialDateTypeLabel(type)),
|
||||||
|
position = if (on) Position.Top else Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = on,
|
||||||
|
onCheckedChange = { want ->
|
||||||
|
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
else confirmDisableType = type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (on) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_template),
|
||||||
|
summary = state.titleTemplates[type].orEmpty(),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { editTemplate = type },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { reminderPickerType = type },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_show_year),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showYear,
|
||||||
|
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||||
|
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = viewModel::syncSpecialDatesNow,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDisableAll) {
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||||
|
onDismiss = { confirmDisableAll = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
confirmDisableType?.let { type ->
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(
|
||||||
|
R.string.settings_special_dates_disable_type_message,
|
||||||
|
stringResource(specialDateTypeLabel(type)),
|
||||||
|
),
|
||||||
|
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||||
|
onDismiss = { confirmDisableType = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editTemplate?.let { type ->
|
||||||
|
SpecialDatesTemplateDialog(
|
||||||
|
initial = state.titleTemplates[type].orEmpty(),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||||
|
onDismiss = { editTemplate = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reminderPickerType?.let { type ->
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
||||||
|
// Managed calendars own their reminders outright — no "inherit global".
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||||
|
onDismiss = { reminderPickerType = null },
|
||||||
|
// Special dates are all-day events, so their reminders fire at the
|
||||||
|
// one global all-day hour like any other all-day reminder.
|
||||||
|
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||||
|
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
||||||
|
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesDisableDialog(
|
||||||
|
message: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||||
|
text = { Text(message) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesTemplateDialog(
|
||||||
|
initial: String,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var text by rememberSaveable { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
// The app's borderless input over a tonal surface (the dialog
|
||||||
|
// convention — see DialogControls), not Material's outlined field.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
placeholder = stringResource(R.string.settings_special_dates_template),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(text) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
) { Text(stringResource(R.string.dialog_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||||
|
if (lastRun <= 0L) {
|
||||||
|
stringResource(R.string.settings_special_dates_never_synced)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_special_dates_last_synced,
|
||||||
|
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||||
|
lastRun,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
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.ReorderableColumn
|
||||||
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Views: everything that changes how a calendar view reads, grouped by the view
|
||||||
|
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||||
|
*
|
||||||
|
* The first group holds what applies everywhere (default view, week start, time
|
||||||
|
* format); the rest are per-view. Anything that styles the *app* rather than a
|
||||||
|
* view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the
|
||||||
|
* agenda settings are in [WidgetsScreen].
|
||||||
|
*
|
||||||
|
* The quick-switch cycle and the navigation-drawer list are two independent
|
||||||
|
* orders — a view disabled in the quick-switch cycle is still reachable from the
|
||||||
|
* drawer, which always lists every view. The switch needs at least two targets,
|
||||||
|
* so the last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ViewsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_views),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
// What holds for every view, above the per-view groups.
|
||||||
|
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
summary = weekStartLabel(state.weekStart),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showWeekStart = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
summary = timeFormatLabel(state.timeFormat),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showTimeFormat = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_today_toolbar),
|
||||||
|
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.todayButtonInToolbar,
|
||||||
|
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_dim_completed),
|
||||||
|
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dimCompletedEvents,
|
||||||
|
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_month_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
summary = stringResource(state.monthViewStyle.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showMonthStyle = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_week_numbers),
|
||||||
|
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showWeekNumbers,
|
||||||
|
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showHourLines,
|
||||||
|
onCheckedChange = viewModel::setShowHourLines,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showAgendaScreenRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showPastEvents = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowToday,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowToday,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowRangeBar,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Turning a view off is blocked once only the minimum remain enabled.
|
||||||
|
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||||
|
ReorderableColumn(
|
||||||
|
items = config.order,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
val checked = view in config.enabled
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
dimmed = !checked,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
// Keep the last two on: with fewer, the pill can't switch.
|
||||||
|
enabled = !checked || canDisable,
|
||||||
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ReorderableColumn(
|
||||||
|
items = state.drawerViewOrder,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setDrawerViewOrder(it) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMonthStyle) {
|
||||||
|
MonthViewStylePicker(
|
||||||
|
selected = state.monthViewStyle,
|
||||||
|
// The preview is a real grid, so it uses the real week start too.
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onDismiss = { showMonthStyle = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultView) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = IMPLEMENTED_VIEWS,
|
||||||
|
selected = state.defaultView,
|
||||||
|
label = { stringResource(it.labelRes) },
|
||||||
|
// The same icon each view carries in the drawer and the switcher
|
||||||
|
// pill, so the row is matched to the view by eye, not by reading.
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = it.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSelect = viewModel::setDefaultView,
|
||||||
|
onDismiss = { showDefaultView = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showWeekStart) {
|
||||||
|
WeekStartPicker(
|
||||||
|
selected = state.weekStart,
|
||||||
|
// The preview is a real grid, so it uses the user's own month style.
|
||||||
|
monthStyle = state.monthViewStyle,
|
||||||
|
options = WEEK_START_OPTIONS,
|
||||||
|
onSelect = viewModel::setWeekStart,
|
||||||
|
onDismiss = { showWeekStart = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTimeFormat) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = TimeFormatPref.entries,
|
||||||
|
selected = state.timeFormat,
|
||||||
|
label = { timeFormatLabel(it) },
|
||||||
|
// Each option renders the same sample time the way it would write
|
||||||
|
// it — an afternoon one, since that is where 12h and 24h diverge.
|
||||||
|
// "Automatic" additionally says which of the two it resolves to now.
|
||||||
|
summary = { pref ->
|
||||||
|
val sample = formatTimeOfDay(
|
||||||
|
hour = SAMPLE_HOUR,
|
||||||
|
minute = SAMPLE_MINUTE,
|
||||||
|
is24Hour = when (pref) {
|
||||||
|
TimeFormatPref.AUTO -> systemIs24Hour
|
||||||
|
TimeFormatPref.TWELVE_HOUR -> false
|
||||||
|
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||||
|
},
|
||||||
|
locale = locale,
|
||||||
|
)
|
||||||
|
if (pref == TimeFormatPref.AUTO) {
|
||||||
|
stringResource(R.string.settings_time_format_auto_summary, sample)
|
||||||
|
} else {
|
||||||
|
sample
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelect = viewModel::setTimeFormat,
|
||||||
|
onDismiss = { showTimeFormat = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPastEvents) {
|
||||||
|
PastEventsPicker(
|
||||||
|
selected = state.pastEventDisplay,
|
||||||
|
onSelect = viewModel::setPastEventDisplay,
|
||||||
|
onDismiss = { showPastEvents = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAgendaScreenRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_range_hint),
|
||||||
|
selected = state.agendaScreenRange,
|
||||||
|
// Resolving each option to real dates needs the same week start the
|
||||||
|
// agenda itself windows by.
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setAgendaScreenRange,
|
||||||
|
onDismiss = { showAgendaScreenRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
|
||||||
|
private const val SAMPLE_HOUR = 14
|
||||||
|
private const val SAMPLE_MINUTE = 30
|
||||||
|
|
||||||
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
|
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewRow(
|
||||||
|
view: CalendarView,
|
||||||
|
position: Position,
|
||||||
|
isDragging: Boolean,
|
||||||
|
dragHandle: Modifier,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = position,
|
||||||
|
dimmed = dimmed,
|
||||||
|
minHeight = ReorderableRowHeight,
|
||||||
|
// The reorderable column owns the inter-row spacing (uniform pitch).
|
||||||
|
gapBelow = false,
|
||||||
|
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = view.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
trailing?.invoke()
|
||||||
|
if (trailing != null) Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = dragHandle.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = stringResource(R.string.reorder_drag_handle),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||||
|
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||||
|
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||||
|
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||||
|
// Localised full weekday name, so any of the seven days reads naturally
|
||||||
|
// without a per-day string resource.
|
||||||
|
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||||
|
when (pref) {
|
||||||
|
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||||
|
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||||
|
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
|
||||||
|
stringResource(pastEventDisplayLabelRes(mode))
|
||||||
|
|
||||||
|
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
|
||||||
|
@StringRes
|
||||||
|
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
|
||||||
|
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||||
|
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||||
|
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The week-start chooser, with the grid it rearranges shown above it.
|
||||||
|
*
|
||||||
|
* Which column a week opens on is the most visual setting in the app, and the
|
||||||
|
* one hardest to hold in your head from a weekday name alone. The preview is
|
||||||
|
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
|
||||||
|
* *own* month style, so what moves is exactly what will move on the real
|
||||||
|
* screen. It is short here (the option list runs to eight rows), which the
|
||||||
|
* preview supports natively by scaling.
|
||||||
|
*
|
||||||
|
* "Follow the system" carries the day it currently resolves to as its summary —
|
||||||
|
* otherwise the one automatic option is the only one whose effect is invisible.
|
||||||
|
*
|
||||||
|
* A preview picker, so selecting applies at once and keeps the picker open;
|
||||||
|
* back exits (see `FullScreenPicker`).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun WeekStartPicker(
|
||||||
|
selected: WeekStartPref,
|
||||||
|
monthStyle: MonthViewStyle,
|
||||||
|
options: List<WeekStartPref>,
|
||||||
|
onSelect: (WeekStartPref) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val resolved = selected.resolveFirstDay(locale)
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = resolved,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "week-start-preview",
|
||||||
|
) { day ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
MonthStylePreview(
|
||||||
|
style = monthStyle,
|
||||||
|
weekStart = day,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = weekStartLabel(option),
|
||||||
|
// Only the automatic option needs a second line: it is the one
|
||||||
|
// whose effect the label doesn't name.
|
||||||
|
summary = if (option == WeekStartPref.Auto) {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_week_start_auto_summary,
|
||||||
|
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(option) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short enough to leave the first options on screen under it. */
|
||||||
|
private val PREVIEW_HEIGHT = 200.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.app.StatusBarManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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 de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||||
|
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.locale.currentLocale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
|
||||||
|
* Settings tile shortcut — the app's system surfaces, collected in one place.
|
||||||
|
*
|
||||||
|
* The agenda widget keeps its own range and text size, deliberately separate
|
||||||
|
* from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a
|
||||||
|
* screen is browsed, and having the two rows sit next to each other (as they did
|
||||||
|
* before) made them easy to mistake for one another.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun WidgetsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
|
var showWidgetSize by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_widgets),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showAgendaWidgetRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_widget_size),
|
||||||
|
summary = widgetSizeLabel(state.widgetSize),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showWidgetSize = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
|
// the QS editor, so the row simply doesn't appear here.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
QuickSettingsTileRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAgendaWidgetRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||||
|
selected = state.agendaWidgetRange,
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setAgendaWidgetRange,
|
||||||
|
onDismiss = { showAgendaWidgetRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showWidgetSize) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_widget_size),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = WidgetSize.entries,
|
||||||
|
selected = state.widgetSize,
|
||||||
|
label = { widgetSizeLabel(it) },
|
||||||
|
// A size name says nothing about what you get; the text scale it
|
||||||
|
// draws at does. No live preview here on purpose: the widget is
|
||||||
|
// Glance/RemoteViews, so a Compose mock-up would be a second
|
||||||
|
// implementation free to drift from the real thing.
|
||||||
|
summary = { widgetSizeSummary(it) },
|
||||||
|
onSelect = viewModel::setWidgetSize,
|
||||||
|
onDismiss = { showWidgetSize = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
private fun QuickSettingsTileRow() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_qs_tile),
|
||||||
|
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { requestAddQsTile(context) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
||||||
|
* shows its own confirmation dialog and handles the already-added case, so no
|
||||||
|
* result handling is needed here.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
private fun requestAddQsTile(context: Context) {
|
||||||
|
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
||||||
|
statusBar.requestAddTileService(
|
||||||
|
ComponentName(context, NewEventTileService::class.java),
|
||||||
|
context.getString(R.string.qs_tile_new_event_label),
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
||||||
|
context.mainExecutor,
|
||||||
|
) { /* result code unused — the system surfaces its own feedback */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a size step actually does, as a percentage of the smallest one's event
|
||||||
|
* text. Derived from the widget's own [metricsFor] table rather than written
|
||||||
|
* out here, so a retuned step can't leave this line claiming the old number.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||||
|
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||||
|
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||||
|
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||||
|
when (size) {
|
||||||
|
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||||
|
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||||
|
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||||
|
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -155,6 +155,9 @@
|
|||||||
<!-- Shown in place of the live rule read-out when an amount field holds a
|
<!-- Shown in place of the live rule read-out when an amount field holds a
|
||||||
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
||||||
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
||||||
|
<!-- %1$s is a list of the next few dates, e.g. "30 Jul, 6 Aug and 13 Aug". -->
|
||||||
|
<string name="event_edit_recurrence_next">Next: %1$s</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">This rule never repeats</string>
|
||||||
<string name="event_edit_recurrence_ends">Ends</string>
|
<string name="event_edit_recurrence_ends">Ends</string>
|
||||||
<string name="event_edit_recurrence_end_never">Never</string>
|
<string name="event_edit_recurrence_end_never">Never</string>
|
||||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||||
@@ -198,6 +201,12 @@
|
|||||||
<string name="event_availability_free">Free</string>
|
<string name="event_availability_free">Free</string>
|
||||||
<string name="event_access_private">Private</string>
|
<string name="event_access_private">Private</string>
|
||||||
<string name="event_access_confidential">Confidential</string>
|
<string name="event_access_confidential">Confidential</string>
|
||||||
|
<!-- Second lines in the visibility picker: what each level means to people
|
||||||
|
the calendar is shared with. -->
|
||||||
|
<string name="event_access_default_summary">Whatever this calendar normally does</string>
|
||||||
|
<string name="event_access_public_summary">Everyone with access sees the full details</string>
|
||||||
|
<string name="event_access_private_summary">Others see only that you\'re busy</string>
|
||||||
|
<string name="event_access_confidential_summary">Marked confidential; what that means is up to the calendar account</string>
|
||||||
<string name="event_attendee_organizer">Organizer</string>
|
<string name="event_attendee_organizer">Organizer</string>
|
||||||
<string name="event_attendee_optional">Optional</string>
|
<string name="event_attendee_optional">Optional</string>
|
||||||
<string name="event_attendee_resource">Resource</string>
|
<string name="event_attendee_resource">Resource</string>
|
||||||
@@ -315,6 +324,9 @@
|
|||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</string>
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
|
||||||
|
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
|
||||||
|
<string name="settings_theme_system_summary">Currently %1$s</string>
|
||||||
<string name="settings_default_view">Default view</string>
|
<string name="settings_default_view">Default view</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
@@ -329,8 +341,14 @@
|
|||||||
<string name="settings_font_choose_file">Choose file…</string>
|
<string name="settings_font_choose_file">Choose file…</string>
|
||||||
<string name="settings_font_custom_selected">Custom font</string>
|
<string name="settings_font_custom_selected">Custom font</string>
|
||||||
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||||
|
<!-- Specimen line shown in the headings-font picker, set in the chosen face. -->
|
||||||
|
<string name="settings_font_specimen_heading">Thursday, 14 May</string>
|
||||||
|
<!-- Specimen paragraph shown in the body-font picker, set in the chosen face. -->
|
||||||
|
<string name="settings_font_specimen_body">Team review at 10:00, then lunch with Robin at the café on Marktplatz.</string>
|
||||||
<string name="settings_week_start">Week starts on</string>
|
<string name="settings_week_start">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
|
<!-- %1$s is the weekday the automatic setting currently resolves to, e.g. "Monday". -->
|
||||||
|
<string name="settings_week_start_auto_summary">Currently %1$s</string>
|
||||||
<string name="settings_week_numbers">Week numbers</string>
|
<string name="settings_week_numbers">Week numbers</string>
|
||||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||||
@@ -341,6 +359,8 @@
|
|||||||
<string name="settings_time_format_auto">Automatic</string>
|
<string name="settings_time_format_auto">Automatic</string>
|
||||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||||
|
<!-- %1$s is a sample time written the way the system currently writes it. -->
|
||||||
|
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||||
<string name="settings_hour_lines">Hour lines</string>
|
<string name="settings_hour_lines">Hour lines</string>
|
||||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||||
<string name="settings_dim_completed">Dim completed events</string>
|
<string name="settings_dim_completed">Dim completed events</string>
|
||||||
@@ -349,6 +369,10 @@
|
|||||||
<string name="settings_past_events_show">Show</string>
|
<string name="settings_past_events_show">Show</string>
|
||||||
<string name="settings_past_events_dim">Dim</string>
|
<string name="settings_past_events_dim">Dim</string>
|
||||||
<string name="settings_past_events_hide">Hide</string>
|
<string name="settings_past_events_hide">Hide</string>
|
||||||
|
<!-- Stand-in event titles in the past-events preview. Short, everyday entries. -->
|
||||||
|
<string name="settings_past_events_sample_morning">Team review</string>
|
||||||
|
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
|
||||||
|
<string name="settings_past_events_sample_evening">Choir practice</string>
|
||||||
<string name="settings_agenda_header">Agenda</string>
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
<string name="settings_agenda_range">Agenda range</string>
|
<string name="settings_agenda_range">Agenda range</string>
|
||||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||||
@@ -361,6 +385,8 @@
|
|||||||
<string name="settings_widget_size_medium">Medium</string>
|
<string name="settings_widget_size_medium">Medium</string>
|
||||||
<string name="settings_widget_size_large">Large</string>
|
<string name="settings_widget_size_large">Large</string>
|
||||||
<string name="settings_widget_size_extra_large">Extra large</string>
|
<string name="settings_widget_size_extra_large">Extra large</string>
|
||||||
|
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||||
|
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||||
<string name="settings_agenda_show_today">Always show today</string>
|
<string name="settings_agenda_show_today">Always show today</string>
|
||||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||||
<string name="settings_agenda_range_bar">Range bar</string>
|
<string name="settings_agenda_range_bar">Range bar</string>
|
||||||
@@ -411,6 +437,8 @@
|
|||||||
<string name="settings_default_reminder_allday">All-day events</string>
|
<string name="settings_default_reminder_allday">All-day events</string>
|
||||||
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||||
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
||||||
|
<!-- Second line on an all-day lead-time row. %1$s is a clock time, e.g. "09:00". -->
|
||||||
|
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
|
||||||
<string name="reminder_none">None</string>
|
<string name="reminder_none">None</string>
|
||||||
<string name="reminder_use_default">Use default reminder</string>
|
<string name="reminder_use_default">Use default reminder</string>
|
||||||
<string name="reminder_custom_amount">Amount</string>
|
<string name="reminder_custom_amount">Amount</string>
|
||||||
@@ -430,12 +458,39 @@
|
|||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
<string name="settings_translate">Help translate</string>
|
<string name="settings_translate">Help translate</string>
|
||||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
|
<!-- Hub group headers (#69) -->
|
||||||
|
<string name="settings_group_look">Look & behaviour</string>
|
||||||
|
<string name="settings_group_data">Data</string>
|
||||||
|
<string name="settings_group_app">App</string>
|
||||||
|
<!-- Group of reference links at the foot of the settings hub. -->
|
||||||
|
<string name="settings_group_about">About</string>
|
||||||
|
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, colours, fonts</string>
|
||||||
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
|
<string name="settings_views_subtitle">Default view, layout, order</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
||||||
|
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||||
|
|
||||||
|
<!-- Widgets & tiles (#69) -->
|
||||||
|
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||||
|
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
||||||
|
|
||||||
|
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||||
|
<string name="settings_section_backup">Backup & restore</string>
|
||||||
|
|
||||||
|
<!-- Views sub-headers (#69) -->
|
||||||
|
<string name="settings_views_all_header">All views</string>
|
||||||
|
<string name="settings_week_day_header">Week & day</string>
|
||||||
|
|
||||||
|
<!-- Picker descriptions (#69) -->
|
||||||
|
<string name="settings_default_view_hint">The view Calendula opens on when you start it.</string>
|
||||||
|
<string name="settings_week_start_hint">The day every week begins on, in all views and widgets.</string>
|
||||||
|
<string name="settings_time_format_hint">How times are written throughout the app. Automatic follows your system setting.</string>
|
||||||
|
<string name="settings_past_events_hint">What the agenda does with events that have already ended.</string>
|
||||||
|
<string name="settings_dynamic_color_summary">Take the app\'s colours from your wallpaper.</string>
|
||||||
|
|
||||||
<!-- Contact special dates (issue #15) -->
|
<!-- Contact special dates (issue #15) -->
|
||||||
<string name="settings_section_special_dates">Contact special dates</string>
|
<string name="settings_section_special_dates">Contact special dates</string>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class RecurrenceOccurrencesTest {
|
||||||
|
|
||||||
|
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daily rule starts at the event's own date`() {
|
||||||
|
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
|
||||||
|
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 30),
|
||||||
|
date(2026, 7, 31),
|
||||||
|
date(2026, 8, 1),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interval multiplies the period`() {
|
||||||
|
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
|
||||||
|
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 30),
|
||||||
|
date(2026, 8, 2),
|
||||||
|
date(2026, 8, 5),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly without weekday picks repeats the start's weekday`() {
|
||||||
|
// 30 Jul 2026 is a Thursday.
|
||||||
|
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
|
||||||
|
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 30),
|
||||||
|
date(2026, 8, 6),
|
||||||
|
date(2026, 8, 13),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly with picks fires on every chosen day, in weekday order`() {
|
||||||
|
val occurrences = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Weekly,
|
||||||
|
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
|
||||||
|
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
|
||||||
|
|
||||||
|
// Thursday start: the Monday of that week is already past, so the first
|
||||||
|
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 31),
|
||||||
|
date(2026, 8, 3),
|
||||||
|
date(2026, 8, 7),
|
||||||
|
date(2026, 8, 10),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly interval skips whole weeks, counted from Monday`() {
|
||||||
|
val occurrences = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Weekly,
|
||||||
|
interval = 2,
|
||||||
|
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
|
||||||
|
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||||
|
|
||||||
|
// Start week (27 Jul–2 Aug) contributes only its Thursday; the next block
|
||||||
|
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 30),
|
||||||
|
date(2026, 8, 10),
|
||||||
|
date(2026, 8, 13),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly skips months without the start day rather than clamping`() {
|
||||||
|
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||||
|
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
|
||||||
|
|
||||||
|
// February, April and June have no 31st — the rule passes them by.
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 1, 31),
|
||||||
|
date(2026, 3, 31),
|
||||||
|
date(2026, 5, 31),
|
||||||
|
date(2026, 7, 31),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `yearly on 29 February only lands in leap years`() {
|
||||||
|
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
|
||||||
|
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2024, 2, 29),
|
||||||
|
date(2028, 2, 29),
|
||||||
|
date(2032, 2, 29),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count limits the series and skipped periods don't consume one`() {
|
||||||
|
val occurrences = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Monthly,
|
||||||
|
end = RecurrenceEnd.Count(3),
|
||||||
|
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 1, 31),
|
||||||
|
date(2026, 3, 31),
|
||||||
|
date(2026, 5, 31),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `until is inclusive of its own date`() {
|
||||||
|
val occurrences = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Daily,
|
||||||
|
end = RecurrenceEnd.Until(date(2026, 8, 1)),
|
||||||
|
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
|
||||||
|
|
||||||
|
assertThat(occurrences).containsExactly(
|
||||||
|
date(2026, 7, 30),
|
||||||
|
date(2026, 7, 31),
|
||||||
|
date(2026, 8, 1),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an until before the start yields nothing instead of spinning`() {
|
||||||
|
val occurrences = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Daily,
|
||||||
|
end = RecurrenceEnd.Until(date(2026, 7, 1)),
|
||||||
|
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||||
|
|
||||||
|
assertThat(occurrences).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a run inside one year needs no year, one that leaves it does`() {
|
||||||
|
val start = date(2026, 7, 30)
|
||||||
|
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
|
||||||
|
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
|
||||||
|
|
||||||
|
// Yearly: same day and month every time, so the year is the only thing
|
||||||
|
// telling the three dates apart.
|
||||||
|
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
|
||||||
|
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
|
||||||
|
|
||||||
|
// Monthly rolling past December.
|
||||||
|
val newYearStart = date(2026, 11, 30)
|
||||||
|
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||||
|
.upcomingOccurrences(newYearStart, limit = 3)
|
||||||
|
assertThat(overflowing).containsExactly(
|
||||||
|
date(2026, 11, 30),
|
||||||
|
date(2026, 12, 30),
|
||||||
|
date(2027, 1, 30),
|
||||||
|
).inOrder()
|
||||||
|
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `limit and count are both respected, whichever is smaller`() {
|
||||||
|
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
|
||||||
|
|
||||||
|
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
|
||||||
|
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
|
||||||
|
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,9 @@ flowchart TD
|
|||||||
- **`ui/`** — one package per screen, each with Screen + ViewModel +
|
- **`ui/`** — one package per screen, each with Screen + ViewModel +
|
||||||
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
|
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
|
||||||
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
|
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
|
||||||
drawer, transitions).
|
drawer, transitions). `ui/settings/` is the exception to "one file per
|
||||||
|
screen": one `SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a
|
||||||
|
sub-screen per category, each in its own `*Settings.kt`.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
|
|||||||
Submodule floret-kit updated: b4d3ead8f0...ed1d3ca5e8
Reference in New Issue
Block a user