Compare commits

...
19 changed files with 582 additions and 88 deletions

No files matched your search

@@ -365,6 +365,7 @@ class MainActivity : AppCompatActivity() {
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
rrule = getStringExtra(CalendarContract.Events.RRULE),
calendarId = calendarIdExtraOrNull(),
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
@@ -425,6 +426,15 @@ class MainActivity : AppCompatActivity() {
private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null
/** The calendar an insert intent names (#429); callers pass it as an int, a long or a string. */
@Suppress("DEPRECATION")
private fun Intent.calendarIdExtraOrNull(): Long? =
when (val value = extras?.get(CalendarContract.Events.CALENDAR_ID)) {
is Number -> value.toLong()
is String -> value.trim().toLongOrNull()
else -> null
}?.takeIf { it > 0 }
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.domain.DefaultCalendarRef
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
@@ -163,6 +164,7 @@ class SettingsPrefs @Inject constructor(
val defaultFormFields: Flow<Set<EventFormField>> = prefs.flow(SettingsKeys.defaultFormFields)
val recentTimeZones: Flow<List<String>> = prefs.flow(SettingsKeys.recentTimeZones)
val autofocusEventTitle: Flow<Boolean> = prefs.flow(SettingsKeys.autofocusEventTitle)
val defaultNewEventCalendar: Flow<DefaultCalendarRef?> = prefs.flow(SettingsKeys.defaultNewEventCalendar)
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
@@ -257,6 +259,10 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutofocusEventTitle(enabled: Boolean) = prefs.set(SettingsKeys.autofocusEventTitle, enabled)
/** [calendar] null goes back to Automatic. */
suspend fun setDefaultNewEventCalendar(calendar: DefaultCalendarRef?) =
prefs.set(SettingsKeys.defaultNewEventCalendar, calendar)
suspend fun setDefaultEventDurationMinutes(minutes: Int) =
prefs.set(SettingsKeys.defaultEventDurationMinutes, minutes)
@@ -570,6 +576,14 @@ object SettingsKeys {
*/
val autofocusEventTitle: Pref<Boolean> = booleanPref("autofocus_event_title", true)
/**
* The calendar new events open in (#429); null is Automatic (the last-used
* calendar). Kept when the calendar stops being a target, which only makes
* it fall back for as long as that lasts.
*/
val defaultNewEventCalendar: Pref<DefaultCalendarRef?> =
nullableStringPref("default_new_event_calendar").map(DefaultCalendarRef::decode) { it?.encode() }
/**
* How long a new **timed** event lasts, in minutes (#54). Per-calendar
* overrides in [perCalendarEventDuration] take precedence; all-day events
@@ -320,17 +320,17 @@ fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
* event. The source calendar is only requested: the editor keeps it while it can
* take new events and otherwise resolves the calendar like a new event, which
* lets a read-only event be copied into an editable one. [instance]'s own times
* carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
fun EventDetail.toDuplicateForm(zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
)
@@ -13,9 +13,9 @@ import kotlin.time.Instant
* back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour, and a placeholder hour for a missing end that the form then
* stretches to the default-duration setting (the intent is opened as
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths.
* `ImportSource.InsertOpenEnded`, #54). [calendarId] is the calendar the
* intent names, if any; the editor preselects it while it is a target and otherwise
* resolves the calendar like a plain new event (#429).
*
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
* the extras lives in `MainActivity.insertFormOrNull`.
@@ -28,6 +28,7 @@ fun buildInsertEventForm(
description: String?,
location: String? = null,
rrule: String? = null,
calendarId: Long? = null,
zone: TimeZone,
now: Instant,
): EventForm {
@@ -44,7 +45,7 @@ fun buildInsertEventForm(
startAt.toFormTime(zone) to endAt.toFormTime(zone)
}
return EventForm(
calendarId = null,
calendarId = calendarId,
title = title.orEmpty(),
isAllDay = isAllDay,
start = start,
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.calendula.domain
/**
* The calendar chosen as default for new events (#429). Provider ids are
* device-local and get reassigned when an account is re-added or a backup is
* restored onto another device, so the account and name ride along: see [find].
*/
data class DefaultCalendarRef(
val id: Long,
val accountType: String,
val accountName: String,
val displayName: String,
) {
/**
* The calendar this refers to: the exact one, else the account's calendar of
* that name (the id changed), else the id within the account (it was renamed).
*/
fun find(calendars: List<CalendarSource>): CalendarSource? {
val inAccount = calendars.filter { it.accountType == accountType && it.accountName == accountName }
return inAccount.firstOrNull { it.id == id && it.displayName == displayName }
?: inAccount.singleOrNull { it.displayName == displayName }
?: inAccount.firstOrNull { it.id == id }
}
fun encode(): String = listOf(id.toString(), accountType, accountName, displayName).joinToString(SEPARATOR)
companion object {
private const val SEPARATOR = "\u001F"
fun of(calendar: CalendarSource) =
DefaultCalendarRef(calendar.id, calendar.accountType, calendar.accountName, calendar.displayName)
fun decode(stored: String?): DefaultCalendarRef? {
val parts = stored?.split(SEPARATOR, limit = 4)?.takeIf { it.size == 4 } ?: return null
val id = parts[0].toLongOrNull() ?: return null
return DefaultCalendarRef(id, parts[1], parts[2], parts[3])
}
}
}
/**
* What decides a new event's calendar when the user hasn't picked one (#429).
* [requested] is a calendar named by whoever opened the form (an insert intent,
* a duplicate's source), [default] the "default calendar" setting and
* [lastUsed] the calendar the last new event went into.
*/
data class NewEventCalendarHints(
val requested: Long? = null,
val default: DefaultCalendarRef? = null,
val lastUsed: Long? = null,
)
/**
* The calendar the event form targets: [picked] when set, otherwise the first of
* the requested, default and last-used calendar that is still an event target,
* then the first target.
*/
fun resolveEventCalendar(
picked: Long?,
hints: NewEventCalendarHints,
calendars: List<CalendarSource>,
): Long? {
fun Long.ifTarget() = takeIf { id -> calendars.any { it.id == id && it.isEventTarget } }
return picked
?: hints.requested?.ifTarget()
?: effectiveDefaultCalendar(hints.default, calendars)?.id
?: hints.lastUsed?.ifTarget()
?: calendars.firstOrNull { it.isEventTarget }?.id
}
/**
* The default calendar setting as it applies now: null ("Automatic") when unset,
* or when the stored calendar is gone, read-only or switched off.
*/
fun effectiveDefaultCalendar(default: DefaultCalendarRef?, calendars: List<CalendarSource>): CalendarSource? =
default?.find(calendars)?.takeIf { it.isEventTarget }
@@ -10,7 +10,7 @@ import kotlinx.datetime.toLocalDateTime
/**
* Prefill the create form from a single parsed `.ics` event (the "open one
* event" path). [calendarId] is left null so the form preselects the last-used
* event" path). [calendarId] is left null so the form preselects the default
* calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end).
@@ -64,8 +64,7 @@ class EventActionsViewModel @Inject constructor(
/** A create form copying [event], or null when it can't be read. */
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, zoneSource.current())
return detail.toDuplicateForm(zoneSource.current())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -142,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
return loaded.detail.toDuplicateForm(zoneSource.current())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -12,7 +12,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
* form means the screen hasn't been opened yet.
*/
data class EventEditUiState(
/** The form with its calendar id resolved (picked > last used > first writable). */
/** The form with its calendar id resolved, see [de.jeanlucmakiola.calendula.domain.resolveEventCalendar]. */
val form: EventForm,
/** Calendars that accept writes — the only valid targets. */
val calendars: List<CalendarSource>,
@@ -33,6 +33,8 @@ import de.jeanlucmakiola.calendula.domain.withStart
import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.withTimezone
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.NewEventCalendarHints
import de.jeanlucmakiola.calendula.domain.resolveEventCalendar
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.isEventTarget
@@ -142,9 +144,9 @@ data class ImportReminderPrompt(
)
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
* shows and what gets saved.
* Holds the event form being composed. The form's calendar id resolves through
* [resolveEventCalendar] (user pick > requested > default > last used > first
* target); the resolved value is what the UI shows and what gets saved.
*/
@HiltViewModel
class EventEditViewModel @Inject constructor(
@@ -181,6 +183,9 @@ class EventEditViewModel @Inject constructor(
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
// A calendar named by an insert intent or a duplicate's source (#429); wins
// over the default calendar while it is an event target.
private val _requestedCalendarId = MutableStateFlow<Long?>(null)
// The form as the user received it: the loaded event, or a new form with its
// prefill and auto-applied defaults. Anything else is an unsaved change.
private val _baseline = MutableStateFlow<EventForm?>(null)
@@ -239,7 +244,7 @@ class EventEditViewModel @Inject constructor(
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
val hints: NewEventCalendarHints,
val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
@@ -252,8 +257,8 @@ class EventEditViewModel @Inject constructor(
/**
* The calendars a new event can be saved to ([isEventTarget]): writable,
* switched on, not a contact-filled mirror, not a non-syncing subscription.
* A last-used preselect landing on an excluded calendar falls back to the
* first remaining one (handled by [resolvedCalendarId] and [state]).
* A preselect landing on an excluded calendar falls back as
* [resolveEventCalendar] describes.
*
* This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker.
@@ -262,16 +267,20 @@ class EventEditViewModel @Inject constructor(
calendars.filter { it.isEventTarget }
}
private val calendarHints: Flow<NewEventCalendarHints> = combine(
_requestedCalendarId,
settingsPrefs.defaultNewEventCalendar,
prefs.lastUsedCalendarId,
::NewEventCalendarHints,
)
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
_form.map { it?.calendarId },
writableCalendars,
prefs.lastUsedCalendarId,
) { picked, writable, lastUsed ->
picked
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull()?.id
}.distinctUntilChanged()
calendarHints,
allCalendars,
::resolveEventCalendar,
).distinctUntilChanged()
/** The resolved calendar's published event palette, refetched when it changes. */
@OptIn(ExperimentalCoroutinesApi::class)
@@ -285,7 +294,7 @@ class EventEditViewModel @Inject constructor(
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
writableCalendars,
prefs.lastUsedCalendarId,
calendarHints,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
@@ -296,10 +305,14 @@ class EventEditViewModel @Inject constructor(
settingsPrefs.recentTimeZones.flowOn(io),
) { local, external, palette, allCalendars, recentTimeZones ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val resolvedId = resolveEventCalendar(form.calendarId, external.hints, allCalendars)
// A colour is scoped to the calendar it came from, so one that came with
// a requested calendar the form couldn't take goes, as on [setCalendar].
val requestRefused = form.calendarId == null && resolvedId != null &&
external.hints.requested.let { it != null && it != resolvedId }
val resolved = form.copy(calendarId = resolvedId).let {
if (requestRefused) it.copy(colorKey = null, color = null) else it
}
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
// Editing an event in a managed calendar locks its synced fields. Keyed
// off the calendar's durable marker, not a stored id, so it holds after a
@@ -407,8 +420,9 @@ class EventEditViewModel @Inject constructor(
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
* app/widget creating an event, e.g. Google Maps' "add to calendar";
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
* calendar still resolves to the last-used/first-writable one.
* [ImportSource.Insert]; #30, #49). A set [EventForm.calendarId] is the
* calendar the caller asked for: it is preselected while it is an event
* target, and the form otherwise resolves its calendar like a new event (#429).
*
* Reminders are handled per [source], because the two paths mean different
* things by "no reminders":
@@ -431,7 +445,8 @@ class EventEditViewModel @Inject constructor(
fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
open(form)
_requestedCalendarId.value = form.calendarId
open(form.copy(calendarId = null))
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
@@ -609,6 +624,7 @@ class EventEditViewModel @Inject constructor(
_remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null
_requestedCalendarId.value = null
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -667,6 +683,11 @@ class EventEditViewModel @Inject constructor(
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) {
// Confirming the calendar the form shows pins it and changes nothing else.
if (id == state.value?.form?.calendarId) {
pinShownCalendar()
return
}
edit { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder and
// length unless the user has already hand-edited them (guarded inside).
@@ -678,13 +699,32 @@ class EventEditViewModel @Inject constructor(
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
fun setColorKey(key: String, argb: Int) {
pinShownCalendar()
edit { it.copy(colorKey = key, color = argb) }
}
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
fun setColorRaw(argb: Int) {
pinShownCalendar()
edit { it.copy(colorKey = null, color = argb) }
}
/** Clear the colour so the event inherits its calendar's. */
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
fun clearColor() {
pinShownCalendar()
edit { it.copy(colorKey = null, color = null) }
}
/**
* Fix a still-resolving form to the calendar and colour it shows, so a colour
* picked for that calendar stays with it. Not a change of its own.
*/
private fun pinShownCalendar() {
val shown = state.value?.form ?: return
if (_form.value?.calendarId != null) return
applyDefault { it.copy(calendarId = shown.calendarId, colorKey = shown.colorKey, color = shown.color) }
}
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoMode
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
@@ -19,7 +21,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.durationLabel
@@ -28,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
@@ -45,6 +51,7 @@ internal fun EventFormScreen(
visible: Boolean = true,
) {
var showDefaultDuration by remember { mutableStateOf(false) }
var showDefaultCalendar by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any.
var durationTarget by remember { mutableStateOf<Long?>(null) }
var durationSectionExpanded by remember { mutableStateOf(false) }
@@ -78,6 +85,26 @@ internal fun EventFormScreen(
)
}
// The calendar a new event opens in (#429).
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_default_calendar),
summary = state.defaultNewEventCalendar?.displayName?.orUnnamed()
?: state.unavailableDefaultCalendar?.let {
stringResource(R.string.settings_default_calendar_unavailable, it.displayName.orUnnamed())
}
?: stringResource(R.string.settings_default_calendar_automatic),
position = Position.Alone,
leading = {
Icon(
imageVector = Icons.Default.Event,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { showDefaultCalendar = true },
)
// Auto-focus the title on a new event (#10), on by default.
Spacer(Modifier.height(24.dp))
GroupedSwitchRow(
@@ -159,6 +186,17 @@ internal fun EventFormScreen(
)
}
if (showDefaultCalendar) {
DefaultCalendarPicker(
calendars = state.eventTargetCalendars,
selectedId = state.defaultNewEventCalendar?.id,
onSelect = { id ->
viewModel.setDefaultNewEventCalendar(state.eventTargetCalendars.firstOrNull { it.id == id })
showDefaultCalendar = false
},
onDismiss = { showDefaultCalendar = false },
)
}
if (showDefaultDuration) {
DurationPicker(
title = stringResource(R.string.settings_event_duration),
@@ -189,5 +227,34 @@ internal fun EventFormScreen(
}
}
/** "Automatic" above every calendar a new event can go into, grouped by account. */
@Composable
private fun DefaultCalendarPicker(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.settings_default_calendar),
onDismiss = onDismiss,
) {
GroupedRow(
title = stringResource(R.string.settings_default_calendar_automatic),
summary = stringResource(R.string.settings_default_calendar_automatic_hint),
position = Position.Alone,
checked = selectedId == null,
leading = { LeadingAvatar(Icons.Default.AutoMode) },
onClick = { onSelect(null) },
)
if (calendars.isNotEmpty()) Spacer(Modifier.height(16.dp))
CalendarPickerGroups(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
)
}
}
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
@@ -98,6 +98,12 @@ data class SettingsUiState(
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/** Calendars a new event can go into: the default calendar picker's choices. */
val eventTargetCalendars: List<CalendarSource> = emptyList(),
/** The default calendar for new events (#429); null is Automatic, also when the stored one fell out. */
val defaultNewEventCalendar: CalendarSource? = null,
/** The stored default while it exists but takes no new events, named on the Automatic row. */
val unavailableDefaultCalendar: CalendarSource? = null,
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
@@ -36,7 +36,10 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.DefaultCalendarRef
import de.jeanlucmakiola.calendula.domain.effectiveDefaultCalendar
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -74,61 +77,66 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
* Every calendar; [SettingsUiState.writableCalendars] keeps the writable,
* switched-on ones — the only ones that take a per-calendar reminder
* override, since a switched-off one plans no reminders at all (#75).
*/
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
private val calendars: Flow<List<CalendarSource>> = repository.calendars()
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, calendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
private fun Preferences.toUiState(calendars: List<CalendarSource>) = SettingsUiState(
themeMode = this[SettingsKeys.themeMode],
dynamicColor = this[SettingsKeys.dynamicColor] && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
softenColors = this[SettingsKeys.softenCalendarColors],
weekStart = this[SettingsKeys.weekStart],
timeFormat = this[SettingsKeys.timeFormat],
showHourGrid = this[SettingsKeys.showHourGrid],
multiDayCount = this[SettingsKeys.multiDayCount],
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
timelineScale = this[SettingsKeys.timelineScale],
timelineHours = timelineHours,
drawerViewOrder = this[SettingsKeys.drawerViewOrder],
defaultFormFields = this[SettingsKeys.defaultFormFields],
autofocusEventTitle = this[SettingsKeys.autofocusEventTitle],
defaultEventDurationMinutes = this[SettingsKeys.defaultEventDurationMinutes],
perCalendarEventDuration = this[SettingsKeys.perCalendarEventDuration],
remindersEnabled = this[SettingsKeys.remindersEnabled],
defaultReminderMinutes = this[SettingsKeys.defaultReminderMinutes],
defaultAllDayReminderMinutes = this[SettingsKeys.defaultAllDayReminderMinutes],
allDayReminderTimeMinutes = this[SettingsKeys.allDayReminderTimeMinutes],
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
private fun Preferences.toUiState(calendars: List<CalendarSource>): SettingsUiState {
val storedDefault = this[SettingsKeys.defaultNewEventCalendar]
return SettingsUiState(
themeMode = this[SettingsKeys.themeMode],
dynamicColor = this[SettingsKeys.dynamicColor] && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
softenColors = this[SettingsKeys.softenCalendarColors],
weekStart = this[SettingsKeys.weekStart],
timeFormat = this[SettingsKeys.timeFormat],
showHourGrid = this[SettingsKeys.showHourGrid],
multiDayCount = this[SettingsKeys.multiDayCount],
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
timelineScale = this[SettingsKeys.timelineScale],
timelineHours = timelineHours,
drawerViewOrder = this[SettingsKeys.drawerViewOrder],
defaultFormFields = this[SettingsKeys.defaultFormFields],
autofocusEventTitle = this[SettingsKeys.autofocusEventTitle],
defaultEventDurationMinutes = this[SettingsKeys.defaultEventDurationMinutes],
perCalendarEventDuration = this[SettingsKeys.perCalendarEventDuration],
remindersEnabled = this[SettingsKeys.remindersEnabled],
defaultReminderMinutes = this[SettingsKeys.defaultReminderMinutes],
defaultAllDayReminderMinutes = this[SettingsKeys.defaultAllDayReminderMinutes],
allDayReminderTimeMinutes = this[SettingsKeys.allDayReminderTimeMinutes],
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
writableCalendars = calendars.filter { it.canModifyContents && it.isVisibleInSystem },
eventTargetCalendars = calendars.filter { it.isEventTarget },
defaultNewEventCalendar = effectiveDefaultCalendar(storedDefault, calendars),
unavailableDefaultCalendar = storedDefault?.find(calendars)?.takeUnless { it.isEventTarget },
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
}
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
@@ -359,6 +367,10 @@ class SettingsViewModel @Inject constructor(
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
/** [calendar] null goes back to Automatic. */
fun setDefaultNewEventCalendar(calendar: CalendarSource?) =
save { setDefaultNewEventCalendar(calendar?.let(DefaultCalendarRef::of)) }
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
/** [minutes] null clears the override, so the calendar inherits the default. */
+4
View File
@@ -705,6 +705,10 @@
<string name="reorder_drag_handle">Drag to reorder</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_default_calendar">Default calendar</string>
<string name="settings_default_calendar_automatic">Automatic</string>
<string name="settings_default_calendar_unavailable">Automatic while %1$s can’t take new events</string>
<string name="settings_default_calendar_automatic_hint">The calendar you last added or moved an event to</string>
<string name="settings_autofocus_title">Focus title on new event</string>
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
<string name="settings_event_duration">Default duration</string>
@@ -583,9 +583,16 @@ class EventFormTest {
@Test
fun `a copy of a cancelled event starts confirmed, a tentative one stays tentative`() {
val zone = TimeZone.of("Europe/Berlin")
assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(canModify = true, zone).status)
assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(zone).status)
.isEqualTo(EventStatus.Confirmed)
assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(canModify = true, zone).status)
assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(zone).status)
.isEqualTo(EventStatus.Tentative)
}
@Test
fun `a copy requests its source calendar and keeps its colour`() {
val form = testDetail(eventColor = 7, eventColorKey = "3").toDuplicateForm(TimeZone.of("Europe/Berlin"))
assertThat(form.calendarId).isEqualTo(testEvent().calendarId)
assertThat(form.colorKey).isEqualTo("3")
}
}
@@ -24,6 +24,7 @@ class InsertEventFormTest {
description: String? = null,
location: String? = null,
rrule: String? = null,
calendarId: Long? = null,
): EventForm = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
@@ -32,6 +33,7 @@ class InsertEventFormTest {
description = description,
location = location,
rrule = rrule,
calendarId = calendarId,
zone = zone,
now = now,
)
@@ -50,7 +52,7 @@ class InsertEventFormTest {
location = "Room 1",
rrule = "FREQ=DAILY",
)
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.calendarId).isNull() // resolved by the editor
assertThat(form.isAllDay).isFalse()
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(end)
@@ -60,6 +62,11 @@ class InsertEventFormTest {
assertThat(form.rrule).isEqualTo("FREQ=DAILY")
}
@Test
fun `a named calendar is carried over`() {
assertThat(build(calendarId = 7L).calendarId).isEqualTo(7L)
}
@Test
fun `missing times default to the next full hour and a one-hour duration`() {
val form = build(title = "Quick note")
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class NewEventCalendarTest {
private fun cal(
id: Long,
writable: Boolean = true,
visible: Boolean = true,
managed: Boolean = false,
) = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = "account",
accountType = "com.google",
color = 0,
isVisibleInSystem = visible,
canModifyContents = writable,
isManaged = managed,
)
private fun ref(id: Long) = DefaultCalendarRef.of(cal(id))
private val calendars = listOf(
cal(1L),
cal(2L),
cal(3L),
cal(4L, writable = false),
cal(5L, visible = false),
cal(6L, managed = true),
)
@Test
fun `a user pick always wins`() {
val hints = NewEventCalendarHints(requested = 2L, default = ref(3L), lastUsed = 1L)
assertThat(resolveEventCalendar(4L, hints, calendars)).isEqualTo(4L)
}
@Test
fun `a requested calendar beats the default`() {
val hints = NewEventCalendarHints(requested = 2L, default = ref(3L))
assertThat(resolveEventCalendar(null, hints, calendars)).isEqualTo(2L)
}
@Test
fun `a requested calendar that is no target falls back to the default`() {
listOf(4L, 5L, 6L, 99L).forEach { requested ->
val hints = NewEventCalendarHints(requested = requested, default = ref(3L))
assertThat(resolveEventCalendar(null, hints, calendars)).isEqualTo(3L)
}
}
@Test
fun `the default beats the last used calendar`() {
val hints = NewEventCalendarHints(default = ref(3L), lastUsed = 2L)
assertThat(resolveEventCalendar(null, hints, calendars)).isEqualTo(3L)
}
@Test
fun `a default that is no target falls back to the last used calendar`() {
listOf(4L, 5L, 6L).forEach { id ->
val default = DefaultCalendarRef.of(calendars.single { it.id == id })
val hints = NewEventCalendarHints(default = default, lastUsed = 2L)
assertThat(resolveEventCalendar(null, hints, calendars)).isEqualTo(2L)
assertThat(effectiveDefaultCalendar(default, calendars)).isNull()
}
assertThat(effectiveDefaultCalendar(ref(99L), calendars)).isNull()
}
@Test
fun `without hints the first target is used`() {
val withoutFirst = listOf(cal(4L, writable = false), cal(5L, visible = false), cal(2L))
assertThat(resolveEventCalendar(null, NewEventCalendarHints(lastUsed = 5L), withoutFirst)).isEqualTo(2L)
assertThat(resolveEventCalendar(null, NewEventCalendarHints(), emptyList())).isNull()
}
@Test
fun `the effective default is the stored calendar while it is a target`() {
assertThat(effectiveDefaultCalendar(ref(3L), calendars)?.id).isEqualTo(3L)
assertThat(effectiveDefaultCalendar(null, calendars)).isNull()
}
@Test
fun `the default survives a rename and a new id, but not another account`() {
assertThat(effectiveDefaultCalendar(ref(3L).copy(displayName = "Old name"), calendars)?.id).isEqualTo(3L)
assertThat(effectiveDefaultCalendar(ref(3L).copy(id = 99L), calendars)?.id).isEqualTo(3L)
// The id was handed to another calendar of the account; the name decides.
assertThat(effectiveDefaultCalendar(ref(3L).copy(id = 2L), calendars)?.id).isEqualTo(3L)
assertThat(effectiveDefaultCalendar(ref(3L).copy(accountName = "other"), calendars)).isNull()
}
@Test
fun `the stored reference round-trips`() {
val stored = DefaultCalendarRef(12L, "com.google", "me@example.com", "Work | Team")
assertThat(DefaultCalendarRef.decode(stored.encode())).isEqualTo(stored)
assertThat(DefaultCalendarRef.decode(null)).isNull()
assertThat(DefaultCalendarRef.decode("12")).isNull()
assertThat(DefaultCalendarRef.decode("x\u001Fa\u001Fb\u001Fc")).isNull()
}
}
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.DefaultCalendarRef
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
@@ -21,6 +22,7 @@ import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -719,4 +721,150 @@ class EventEditViewModelTest {
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
@Test
fun `a new event opens in the default calendar with its reminders`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultNewEventCalendar(DefaultCalendarRef.of(cal(2L)))
s.setCalendarReminderOverride(2L, ReminderOverride.Minutes(listOf(5)))
val p = prefs(tempDir)
p.setLastUsedCalendarId(1L)
val repo = testRepository(fake, tempDir, dispatcher, p, s)
val vm = EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
assertThat(vm.state.value?.form?.reminders).containsExactly(5)
job.cancel()
}
@Test
fun `a switched-off default calendar falls back to the first target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L, visible = false)) }
val s = settings(tempDir)
s.setDefaultNewEventCalendar(DefaultCalendarRef.of(cal(2L)))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15))
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(1L)
job.cancel()
}
@Test
fun `an insert naming a calendar wins over the default`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L), cal(3L)) }
val s = settings(tempDir)
s.setDefaultNewEventCalendar(DefaultCalendarRef.of(cal(2L)))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)).copy(calendarId = 3L),
ImportSource.Insert,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(3L)
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@Test
fun `an insert naming an unknown calendar falls back to the default`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultNewEventCalendar(DefaultCalendarRef.of(cal(2L)))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)).copy(calendarId = 77L),
ImportSource.Insert,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
job.cancel()
}
@Test
fun `a duplicate in its own calendar keeps its colour`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)).copy(calendarId = 2L, colorKey = "3", color = 7),
ImportSource.Insert,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
assertThat(vm.state.value?.form?.colorKey).isEqualTo("3")
job.cancel()
}
@Test
fun `re-picking the shown calendar is no change`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)).copy(calendarId = 2L, colorKey = "3", color = 7),
ImportSource.Insert,
)
advanceUntilIdle()
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.colorKey).isEqualTo("3")
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@Test
fun `a duplicate that can't stay in its calendar drops its colour`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L, visible = false)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)).copy(calendarId = 2L, colorKey = "3", color = 7),
ImportSource.Insert,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(1L)
assertThat(vm.state.value?.form?.colorKey).isNull()
assertThat(vm.state.value?.form?.color).isNull()
vm.setColorRaw(9)
advanceUntilIdle()
assertThat(vm.state.value?.form?.calendarId).isEqualTo(1L)
assertThat(vm.state.value?.form?.color).isEqualTo(9)
job.cancel()
}
}
+2 -1
View File
@@ -62,7 +62,8 @@ flowchart TD
of `data/calendar/` because the receiver needs neither the repository
nor its flows.
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
field defaults, default calendar, reminders toggle) and small state
(last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (recurrence humanizer, FAB column,
drawer, transitions). Selection pickers are full-screen and come from