Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b27bf6ac | ||
|
|
6e1bbf8ec4 | ||
|
|
38e7910d3c | ||
|
|
aa1ce0405a | ||
|
|
6a9274402a | ||
|
|
9577e6c0c8 |
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. */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user