Compare commits

..
25 changed files with 245 additions and 602 deletions

No files matched your search

@@ -365,7 +365,6 @@ 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(),
)
@@ -426,15 +425,6 @@ 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/
@@ -32,6 +32,11 @@ class CalendarPrefs @Inject constructor(
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
/** Rewrite the drawer's hidden set atomically from its current value. */
suspend fun updateHiddenCalendarIds(transform: (Set<Long>) -> Set<Long>) {
prefs.update(HIDDEN_IDS, transform)
}
/**
* Switch-offs the provider does not know about yet, because writing
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
@@ -2,7 +2,6 @@ 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
@@ -164,7 +163,6 @@ 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)
@@ -259,10 +257,6 @@ 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)
@@ -576,14 +570,6 @@ 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 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.
* 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.
*/
fun EventDetail.toDuplicateForm(zone: TimeZone): EventForm =
fun EventDetail.toDuplicateForm(canModify: Boolean, 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). [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).
* `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.
*
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
* the extras lives in `MainActivity.insertFormOrNull`.
@@ -28,7 +28,6 @@ fun buildInsertEventForm(
description: String?,
location: String? = null,
rrule: String? = null,
calendarId: Long? = null,
zone: TimeZone,
now: Instant,
): EventForm {
@@ -45,7 +44,7 @@ fun buildInsertEventForm(
startAt.toFormTime(zone) to endAt.toFormTime(zone)
}
return EventForm(
calendarId = calendarId,
calendarId = null,
title = title.orEmpty(),
isAllDay = isAllDay,
start = start,
@@ -1,76 +0,0 @@
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 default
* event" path). [calendarId] is left null so the form preselects the last-used
* 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,7 +64,8 @@ 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
return detail.toDuplicateForm(zoneSource.current())
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, 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(zoneSource.current())
return loaded.detail.toDuplicateForm(loaded.canModify, 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, see [de.jeanlucmakiola.calendula.domain.resolveEventCalendar]. */
/** The form with its calendar id resolved (picked > last used > first writable). */
val form: EventForm,
/** Calendars that accept writes — the only valid targets. */
val calendars: List<CalendarSource>,
@@ -33,8 +33,6 @@ 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
@@ -144,9 +142,9 @@ data class ImportReminderPrompt(
)
/**
* 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.
* 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.
*/
@HiltViewModel
class EventEditViewModel @Inject constructor(
@@ -183,9 +181,6 @@ 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)
@@ -244,7 +239,7 @@ class EventEditViewModel @Inject constructor(
private data class ExternalInputs(
val writable: List<CalendarSource>,
val hints: NewEventCalendarHints,
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
@@ -257,8 +252,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 preselect landing on an excluded calendar falls back as
* [resolveEventCalendar] describes.
* A last-used preselect landing on an excluded calendar falls back to the
* first remaining one (handled by [resolvedCalendarId] and [state]).
*
* This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker.
@@ -267,20 +262,16 @@ 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 },
calendarHints,
allCalendars,
::resolveEventCalendar,
).distinctUntilChanged()
writableCalendars,
prefs.lastUsedCalendarId,
) { picked, writable, lastUsed ->
picked
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull()?.id
}.distinctUntilChanged()
/** The resolved calendar's published event palette, refetched when it changes. */
@OptIn(ExperimentalCoroutinesApi::class)
@@ -294,7 +285,7 @@ class EventEditViewModel @Inject constructor(
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
writableCalendars,
calendarHints,
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
@@ -305,14 +296,10 @@ class EventEditViewModel @Inject constructor(
settingsPrefs.recentTimeZones.flowOn(io),
) { local, external, palette, allCalendars, recentTimeZones ->
val form = local.form ?: return@combine null
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 resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
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
@@ -420,9 +407,8 @@ 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). 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).
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
* calendar still resolves to the last-used/first-writable one.
*
* Reminders are handled per [source], because the two paths mean different
* things by "no reminders":
@@ -445,8 +431,7 @@ class EventEditViewModel @Inject constructor(
fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
_requestedCalendarId.value = form.calendarId
open(form.copy(calendarId = null))
open(form)
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
@@ -624,7 +609,6 @@ 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. */
@@ -683,11 +667,6 @@ 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).
@@ -699,32 +678,13 @@ 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) {
pinShownCalendar()
edit { it.copy(colorKey = key, color = argb) }
}
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) {
pinShownCalendar()
edit { it.copy(colorKey = null, color = argb) }
}
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
/** Clear the colour so the event inherits its calendar's. */
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) }
}
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
@@ -1,19 +1,29 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -46,7 +56,8 @@ fun CalendarFilterList(
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible,
onToggleCalendar = viewModel::toggleCalendar,
onToggleGroup = viewModel::toggleGroup,
modifier = modifier,
)
}
@@ -61,21 +72,17 @@ fun CalendarFilterList(
private fun FilterList(
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onSetVisible: (Long, Boolean) -> Unit,
onToggleCalendar: (Long) -> Unit,
onToggleGroup: (List<Long>) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
Text(
text = accountGroupTitle(group),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 12.dp,
bottom = 4.dp,
),
val ids = group.calendars.map { it.id }
GroupHeader(
title = accountGroupTitle(group),
state = groupVisibility(ids, hidden),
onToggle = { onToggleGroup(ids) },
)
group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
@@ -87,16 +94,54 @@ private fun FilterList(
trailing = {
Checkbox(
checked = visible,
onCheckedChange = { onSetVisible(cal.id, it) },
onCheckedChange = { onToggleCalendar(cal.id) },
)
},
onClick = { onSetVisible(cal.id, !visible) },
onClick = { onToggleCalendar(cal.id) },
)
}
}
}
}
/**
* An account's header doubling as the group's parent checkbox (#143): checked
* when every calendar shows, indeterminate when some do. Tapping it shows all,
* or hides all when the group is fully shown.
*/
@Composable
private fun GroupHeader(
title: String,
state: ToggleableState,
onToggle: () -> Unit,
) {
val label = stringResource(
if (state.nextGroupVisible()) R.string.filter_group_show_all else R.string.filter_group_hide_all,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset / 2)
.clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = label, role = Role.Checkbox) { onToggle() }
.semantics { toggleableState = state }
.padding(start = GroupedListInset / 2, end = GroupedListInset / 2 + 16.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
TriStateCheckbox(state = state, onClick = null)
}
}
}
@Composable
private fun FilterLoading(modifier: Modifier = Modifier) {
Column(
@@ -14,7 +14,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -48,13 +47,19 @@ class FilterViewModel @Inject constructor(
initialValue = FilterUiState.Loading,
)
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
/**
* Flip one calendar, or a whole group from its header (#143). Both decide
* against the stored set rather than the last drawn state, so quick repeated
* taps still alternate.
*/
fun toggleCalendar(calendarId: Long) {
viewModelScope.launch {
val current = prefs.hiddenCalendarIds.first()
val next = if (visible) current - calendarId else current + calendarId
if (next != current) prefs.setHiddenCalendarIds(next)
prefs.updateHiddenCalendarIds { if (calendarId in it) it - calendarId else it + calendarId }
}
}
fun toggleGroup(calendarIds: List<Long>) {
viewModelScope.launch { prefs.updateHiddenCalendarIds { toggledGroup(it, calendarIds) } }
}
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.ui.state.ToggleableState
/** The drawer group header's checkbox state: on, off, or some of [calendarIds] shown. */
internal fun groupVisibility(calendarIds: Collection<Long>, hidden: Set<Long>): ToggleableState {
val shown = calendarIds.count { it !in hidden }
return when (shown) {
0 -> ToggleableState.Off
calendarIds.size -> ToggleableState.On
else -> ToggleableState.Indeterminate
}
}
/** What tapping the header does: a fully shown group hides, anything else shows all. */
internal fun ToggleableState.nextGroupVisible(): Boolean = this != ToggleableState.On
/** [hidden] after tapping the header of the group holding [calendarIds]. */
internal fun toggledGroup(hidden: Set<Long>, calendarIds: Collection<Long>): Set<Long> =
if (groupVisibility(calendarIds, hidden).nextGroupVisible()) hidden - calendarIds.toSet() else hidden + calendarIds
@@ -5,8 +5,6 @@ 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
@@ -21,10 +19,7 @@ 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
@@ -33,7 +28,6 @@ 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
@@ -51,7 +45,6 @@ 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) }
@@ -85,26 +78,6 @@ 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(
@@ -186,17 +159,6 @@ 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),
@@ -227,34 +189,5 @@ 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,12 +98,6 @@ 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,10 +36,7 @@ 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
@@ -77,66 +74,61 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* 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).
* 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).
*/
private val calendars: Flow<List<CalendarSource>> = repository.calendars()
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, calendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
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(),
)
}
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(),
)
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
@@ -367,10 +359,6 @@ 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. */
+3 -4
View File
@@ -550,6 +550,9 @@
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>
<!-- TalkBack action on a drawer account header that toggles all its calendars -->
<string name="filter_group_show_all">Show all calendars in this group</string>
<string name="filter_group_hide_all">Hide all calendars in this group</string>
<!-- Settings (M4) -->
<string name="settings_title">Settings</string>
@@ -705,10 +708,6 @@
<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>
@@ -34,6 +34,16 @@ class CalendarPrefsTest {
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `updateHiddenCalendarIds transforms the stored set`(@TempDir tempDir: Path) = runTest {
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(9L))
prefs.updateHiddenCalendarIds { it + listOf(1L, 2L) }
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 2L, 9L))
prefs.updateHiddenCalendarIds { emptySet() }
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
val store = testDataStore(tempDir, "test_prefs")
@@ -583,16 +583,9 @@ 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(zone).status)
assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(canModify = true, zone).status)
.isEqualTo(EventStatus.Confirmed)
assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(zone).status)
assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(canModify = true, 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,7 +24,6 @@ class InsertEventFormTest {
description: String? = null,
location: String? = null,
rrule: String? = null,
calendarId: Long? = null,
): EventForm = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
@@ -33,7 +32,6 @@ class InsertEventFormTest {
description = description,
location = location,
rrule = rrule,
calendarId = calendarId,
zone = zone,
now = now,
)
@@ -52,7 +50,7 @@ class InsertEventFormTest {
location = "Room 1",
rrule = "FREQ=DAILY",
)
assertThat(form.calendarId).isNull() // resolved by the editor
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.isAllDay).isFalse()
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(end)
@@ -62,11 +60,6 @@ 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")
@@ -1,102 +0,0 @@
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,7 +10,6 @@ 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
@@ -22,7 +21,6 @@ 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
@@ -721,150 +719,4 @@ 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()
}
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.ui.state.ToggleableState
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class GroupVisibilityTest {
private val group = listOf(1L, 2L, 3L)
@Test
fun `all shown is on and hides on tap`() {
val state = groupVisibility(group, hidden = setOf(9L))
assertThat(state).isEqualTo(ToggleableState.On)
assertThat(state.nextGroupVisible()).isFalse()
}
@Test
fun `none shown is off and shows on tap`() {
val state = groupVisibility(group, hidden = setOf(1L, 2L, 3L))
assertThat(state).isEqualTo(ToggleableState.Off)
assertThat(state.nextGroupVisible()).isTrue()
}
@Test
fun `some shown is indeterminate and shows all on tap`() {
val state = groupVisibility(group, hidden = setOf(2L))
assertThat(state).isEqualTo(ToggleableState.Indeterminate)
assertThat(state.nextGroupVisible()).isTrue()
}
@Test
fun `empty group is off`() {
assertThat(groupVisibility(emptyList(), hidden = emptySet())).isEqualTo(ToggleableState.Off)
}
@Test
fun `toggling twice from the stored set alternates`() {
val hidden = setOf(9L)
val once = toggledGroup(hidden, group)
assertThat(once).isEqualTo(setOf(1L, 2L, 3L, 9L))
assertThat(toggledGroup(once, group)).isEqualTo(setOf(9L))
}
@Test
fun `toggling a partly shown group shows all`() {
assertThat(toggledGroup(setOf(2L, 9L), group)).isEqualTo(setOf(9L))
}
}
+1 -2
View File
@@ -62,8 +62,7 @@ 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, default calendar, reminders toggle) and small state
(last-used calendar).
field defaults, 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