Extract repeated helpers in data, domain and UI layers (#416)
Step 6 of the simplification review: mechanical helper extractions, one commit per group. Widget code is untouched.
**Data layer** (CalendarDataSource and the mappers)
- `updateEventRow`/`eventUri` replace the seven "update, throw if no row" copies and the loose `withAppendedId` spellings.
- `SeriesRow.timeColumns()` / `exdateValues()` define the DTSTART/RRULE/DURATION/TZ/ALL_DAY set once for truncate, split, detach, delete and exception copy.
- One `insertReminderRow` for the four reminder loops, `insertLocalCalendar` for local and managed creates, `applyManagedCalendarReminders` iterates `queryManagedEvents`, `reconcileAttendees` reuses `insertAttendees`, plus `mapRows`, `instancesUri`, `mintUid`, shared `eventColorColumns` and `switchedOffCalendarIds`.
- The read-side mappers share `ColumnReader` helpers (end time, status, all-day UTC date); each keeps its own end-time clamp. `importDuration` goes through `rfc2445Duration` with identical output. The `eventColorPalette` data-source member is gone; the repository calls the pure function.
- One behaviour difference: a provider *exception* while inserting a reminder row was only swallowed on import. It is now logged and skipped on every path (create, update, reconcile, move), as the existing comments already describe it, so it no longer fails the event write.
**Domain**
- `BasicFormat.kt` replaces the four `%04d…` formatters and three substring parsers (ICS writer/parser, Recurrence, RecurrenceText). Parsing stays as lenient as before.
- `rruleParts()` for the RRULE tokenising in five places; each caller keeps its own malformed-part handling. `rruleTruncatedAt` keeps its own tokeniser since it preserves garbage parts verbatim.
- `allDayFormTimes` and `normalizedRrule` for the three form-prefill copies, `zoneOrNull` for the eight zone guards, EventSearch orders months by `YearMonth`, IcsParser uses duration arithmetic and `firstOrNull`.
- FREQ matching no longer depends on the device locale (a lowercase rule failed to parse on Turkish-locale phones).
- Skipped: the contact-date parser rewrite (tests don't pin enough inputs) and the writer-side enum tables (their exhaustive `when` is a useful compile check).
**UI / view models**
- `dayRange(first, last, zone)` and one `startOfWeek` replace four start..end-of-day helpers and two identical week-start functions.
- `orUnnamed()` replaces 17 `ifBlank { calendar_unnamed }` spellings; a shared `CalendarVisibilitySwitch` and `CalendarManagerRow`.
- Filter uses `CalendarAccountGroup` plus a hidden-id set instead of its own copy of the grouping model; the grouping test moved to `AccountGroupsTest`.
- `agendaDays` is shared by the Agenda screen and the split-month pane.
- kotlinx-datetime's `yearMonth`, `firstDay`/`lastDay` and java.time converters replace about 20 hand-written bridges; one `formatTimeOfDay` for agenda rows and month chips.
- No visible UI change.
Net about −240 lines.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/416
This commit is contained in:
+153
-274
@@ -38,7 +38,6 @@ import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
|||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -76,21 +75,13 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
|
||||||
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
|
||||||
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
|
|
||||||
* Empty when the account exposes no palette (most local calendars, some
|
* Empty when the account exposes no palette (most local calendars, some
|
||||||
* CalDAV) — the signal that a custom colour can only be written as a raw
|
* CalDAV) — the signal that a custom colour can only be written as a raw
|
||||||
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
|
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
|
||||||
*/
|
* picker shows it curated ([curatedForPicker]); matching a colour that came
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
* from outside the account runs against this full set, since those keys are
|
||||||
|
* all the calendar accepts.
|
||||||
/**
|
|
||||||
* The same palette **uncurated** — every key the account publishes, in
|
|
||||||
* provider order. Curation is a display concession (it folds look-alikes and
|
|
||||||
* drops the neutrals outright from an oversized palette), so matching a
|
|
||||||
* colour that came from outside the account has to run against the full set:
|
|
||||||
* those keys are all the calendar accepts. See [eventColorPalette].
|
|
||||||
*/
|
*/
|
||||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
@@ -359,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarProjection.COLUMNS,
|
CalendarProjection.COLUMNS,
|
||||||
null, null,
|
null, null,
|
||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
)?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,7 +367,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
|
||||||
|
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert an owned, visible, syncing calendar on the Calendula local account;
|
||||||
|
* [extra] adds the columns that tell the kinds apart. [what] names the write
|
||||||
|
* in the failure — never the calendar name, which can reach a crash report.
|
||||||
|
*/
|
||||||
|
private fun insertLocalCalendar(
|
||||||
|
displayName: String,
|
||||||
|
color: Int,
|
||||||
|
what: String,
|
||||||
|
extra: ContentValues.() -> Unit,
|
||||||
|
): Long {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
@@ -392,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)
|
)
|
||||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
putDescription(description)
|
extra()
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(localCalendarsUri(), values)
|
val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
|
||||||
// No calendar name in the message — it can reach a crash report.
|
|
||||||
?: throw WriteFailedException("create local calendar")
|
|
||||||
return ContentUris.parseId(uri)
|
return ContentUris.parseId(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,30 +465,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun canWriteCalendars(): Boolean =
|
override fun canWriteCalendars(): Boolean =
|
||||||
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
|
||||||
val name = displayName.trim()
|
insertLocalCalendar(displayName, color, "create managed calendar") {
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
|
||||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
|
||||||
put(CalendarContract.Calendars.NAME, name)
|
|
||||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
|
||||||
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
|
||||||
put(
|
|
||||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
|
||||||
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
|
||||||
)
|
|
||||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
|
||||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
|
||||||
// The marker that identifies this as our special-dates calendar,
|
// The marker that identifies this as our special-dates calendar,
|
||||||
// independent of the (user-editable) display name. CAL_SYNC1 already
|
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||||
// holds the description, so the marker lives in CAL_SYNC2.
|
// holds the description, so the marker lives in CAL_SYNC2.
|
||||||
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(localCalendarsUri(), values)
|
|
||||||
?: throw WriteFailedException("create managed calendar")
|
|
||||||
return ContentUris.parseId(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||||
CalendarContract.Calendars.CONTENT_URI,
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// Seeded once, then never re-touched by sync — so a user who moves a
|
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||||
// birthday reminder keeps their change. Encoded against the upcoming
|
// birthday reminder keeps their change. Encoded against the upcoming
|
||||||
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||||
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
insertReminderRows(
|
insertReminderRows(
|
||||||
eventId,
|
eventId,
|
||||||
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
|
||||||
)
|
)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
if (values.isEmpty()) return
|
if (values.isEmpty()) return
|
||||||
val rows = resolver.update(
|
updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(),
|
|
||||||
null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun applyManagedCalendarReminders(
|
override fun applyManagedCalendarReminders(
|
||||||
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
minutes: List<Int>,
|
minutes: List<Int>,
|
||||||
) {
|
) {
|
||||||
resolver.query(
|
// Only our own mirror events, so a stray user event in this calendar
|
||||||
CalendarContract.Events.CONTENT_URI,
|
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
queryManagedEvents(calendarId).forEach { event ->
|
||||||
// Only our own mirror events, so a stray user event in this calendar
|
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||||
// isn't all-day-re-encoded or stripped of its own reminders.
|
reconcileReminders(
|
||||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
event.eventId,
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
|
||||||
"${CalendarContract.Events.UID_2445} LIKE ?",
|
)
|
||||||
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
|
||||||
null,
|
|
||||||
)?.use { c ->
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
val eventId = c.getLong(0)
|
|
||||||
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
|
||||||
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
reconcileReminders(
|
|
||||||
eventId,
|
|
||||||
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
|
||||||
ContentUris.appendId(this, beginMillis)
|
|
||||||
ContentUris.appendId(this, endMillis)
|
|
||||||
}.build()
|
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
instancesUri(beginMillis, endMillis),
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
// Hide cancelled occurrences: "delete only this event" writes a
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
"${CalendarContract.Events.DELETED} = 0",
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
null,
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||||
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
soonestFirst: Boolean,
|
soonestFirst: Boolean,
|
||||||
): Pair<Long, Long>? {
|
): Pair<Long, Long>? {
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
|
||||||
ContentUris.appendId(this, beginMillis)
|
|
||||||
ContentUris.appendId(this, endMillis)
|
|
||||||
}.build()
|
|
||||||
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
instancesUri(beginMillis, endMillis),
|
||||||
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
||||||
"${CalendarContract.Instances.EVENT_ID} = ?",
|
"${CalendarContract.Instances.EVENT_ID} = ?",
|
||||||
arrayOf(eventId.toString()),
|
arrayOf(eventId.toString()),
|
||||||
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
eventUri(eventId),
|
||||||
EventDetailProjection.COLUMNS,
|
EventDetailProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
|
||||||
publishedEventColors(calendarId).curatedForPicker()
|
|
||||||
|
|
||||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
||||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
CalendarContract.Events.DTSTART + " ASC",
|
CalendarContract.Events.DTSTART + " ASC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
c.mapAll {
|
c.mapRows {
|
||||||
val reader = CursorColumnReader(c)
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||||
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
toIcsEvent(
|
||||||
reader.toIcsEvent(
|
|
||||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
calendarName = names[calendarId],
|
calendarName = names[calendarId],
|
||||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||||
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
// only when the source event carried none.
|
// only when the source event carried none.
|
||||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
|
||||||
palette = colorPalette,
|
palette = colorPalette,
|
||||||
)
|
)
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// (see [importedAllDayReminderDate]).
|
// (see [importedAllDayReminderDate]).
|
||||||
val reminderDate = if (event.isAllDay) {
|
val reminderDate = if (event.isAllDay) {
|
||||||
importedAllDayReminderDate(
|
importedAllDayReminderDate(
|
||||||
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
startDate = utcDate(event.start.toEpochMilliseconds()),
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate(),
|
|
||||||
recurrenceRule = event.recurrenceRule,
|
recurrenceRule = event.recurrenceRule,
|
||||||
today = LocalDate.now(zone),
|
today = LocalDate.now(zone),
|
||||||
)
|
)
|
||||||
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
minutes
|
minutes
|
||||||
}
|
}
|
||||||
val reminder = ContentValues().apply {
|
insertReminderRow(eventId, providerMinutes)
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
|
||||||
}
|
|
||||||
// The event row is already in. A reminder that won't attach costs
|
|
||||||
// that one alarm, not the event: throwing here would have the import
|
|
||||||
// count an event it did create as failed, and the retry would then
|
|
||||||
// skip it as a duplicate.
|
|
||||||
val attached = try {
|
|
||||||
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (attached == null) {
|
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
val values = buildEventInsertValues(
|
val values = buildEventInsertValues(
|
||||||
form,
|
form,
|
||||||
uid = "${UUID.randomUUID()}@calendula",
|
uid = mintUid(),
|
||||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||||
).toContentValues().apply {
|
) + eventColorColumns(form.colorKey, form.color)
|
||||||
// A null colour just leaves both columns unset (the event inherits
|
// A new row has nothing to clear: unset columns inherit the
|
||||||
// its calendar's colour), so only the key/raw cases are written.
|
// calendar's colour, so only the key/raw values are written.
|
||||||
when {
|
.filterValues { it != null }
|
||||||
form.colorKey != null ->
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
|
|
||||||
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||||
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||||
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||||
encodedMinutes.forEach { minutes ->
|
encodedMinutes.forEach { insertReminderRow(eventId, it) }
|
||||||
val reminder = ContentValues().apply {
|
}
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
/**
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
* Best effort: the event row is already in, so a reminder that won't attach
|
||||||
}
|
* costs that one alarm, not the event. Throwing would report an event that
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
* was created as failed — and an import retry would then skip it as a
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
* duplicate.
|
||||||
}
|
*/
|
||||||
|
private fun insertReminderRow(
|
||||||
|
eventId: Long,
|
||||||
|
minutes: Int,
|
||||||
|
method: Int = CalendarContract.Reminders.METHOD_ALERT,
|
||||||
|
) {
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, method)
|
||||||
}
|
}
|
||||||
|
val attached = try {
|
||||||
|
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reminder insert threw for event $eventId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
)
|
)
|
||||||
if (values.isNotEmpty()) {
|
if (values.isNotEmpty()) {
|
||||||
val rows = resolver.update(
|
updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(),
|
|
||||||
null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
|
||||||
}
|
}
|
||||||
// Untouched reminder sets are left alone so unrelated edits can't
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
// disturb provider rows the form never knew about. The diff is on the
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
@@ -1067,7 +1009,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: throw WriteFailedException("read event to move id=$eventId")
|
?: throw WriteFailedException("read event to move id=$eventId")
|
||||||
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||||
// recognise the moved event; mint one only if the source carried none.
|
// recognise the moved event; mint one only if the source carried none.
|
||||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
|
||||||
val newEventId = resolver.insert(
|
val newEventId = resolver.insert(
|
||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||||
@@ -1096,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
eventUri(eventId),
|
||||||
MoveMasterProjection.COLUMNS,
|
MoveMasterProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -1108,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||||
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
|
||||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
|
||||||
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||||
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||||
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||||
@@ -1117,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
|
||||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
|
||||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||||
),
|
),
|
||||||
@@ -1135,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
arrayOf(fromEventId.toString()),
|
arrayOf(fromEventId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
|
||||||
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
|
||||||
}
|
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
|
||||||
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: Unit
|
} ?: Unit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1189,22 +1120,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.toDetachedOccurrence()
|
.toDetachedOccurrence()
|
||||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||||
}
|
}
|
||||||
val row = querySeriesRow(toEventId)
|
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
|
||||||
val values = buildOccurrencesExdateValues(
|
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrences = exceptions.map { it.originalInstanceMillis },
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
rdate = row.rdate,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
val updated = resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, toEventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
if (updated == 0) throw WriteFailedException("exdate moved exceptions event id=$toEventId")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||||
@@ -1215,18 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
arrayOf(seriesEventId.toString()),
|
arrayOf(seriesEventId.toString()),
|
||||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
c.mapAll {
|
c.mapRows {
|
||||||
val r = CursorColumnReader(c)
|
|
||||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
|
||||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
|
||||||
ExceptionRowSnapshot(
|
ExceptionRowSnapshot(
|
||||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
exceptionEventId = getLong(ExceptionProjection.IDX_ID),
|
||||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
|
||||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
|
||||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
|
||||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
duration = getString(ExceptionProjection.IDX_DURATION),
|
||||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -1263,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
form.colorKey?.let { key ->
|
form.colorKey?.let { key ->
|
||||||
resolver.update(
|
resolver.update(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
|
eventUri(exceptionId),
|
||||||
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
|
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
|
||||||
null, null,
|
null, null,
|
||||||
)
|
)
|
||||||
@@ -1317,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
// a fresh UID — the detached row is a separate event now, and sharing the
|
||||||
// parent's would collide with it in .ics restore dedup.
|
// parent's would collide with it in .ics restore dedup.
|
||||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrenceMillis = beginMillis,
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
rdate = row.rdate,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
||||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
// exists. It catches the row disappearing under us, not an EXDATE the
|
||||||
// provider's expansion fails to match.
|
// provider's expansion fails to match.
|
||||||
val updatedRows = try {
|
try {
|
||||||
resolver.update(
|
updateEventRow(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
eventId,
|
||||||
values.toContentValues(), null, null,
|
row.exdateValues(listOf(beginMillis)),
|
||||||
|
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||||
)
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
rollBackDetached(detachedId)
|
rollBackDetached(detachedId)
|
||||||
throw t
|
throw t
|
||||||
}
|
}
|
||||||
if (updatedRows == 0) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw WriteFailedException(
|
|
||||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return detachedId
|
return detachedId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
updated = updated,
|
updated = updated,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
) ?: return
|
) ?: return
|
||||||
val row = querySeriesRow(newEventId)
|
val values = querySeriesRow(newEventId).timeColumns().apply {
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Events.EXDATE, carried)
|
put(CalendarContract.Events.EXDATE, carried)
|
||||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
|
||||||
put(CalendarContract.Events.RRULE, row.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, row.duration)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val rows = resolver.update(
|
updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
|
||||||
values, null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) {
|
|
||||||
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
runCatching { deleteEvent(newEventId) }
|
runCatching { deleteEvent(newEventId) }
|
||||||
throw t
|
throw t
|
||||||
@@ -1461,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||||
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||||
val values = ContentValues().apply {
|
val values = row.timeColumns().apply {
|
||||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
|
||||||
put(CalendarContract.Events.DURATION, row.duration)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
|
||||||
put(
|
put(
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val rows = resolver.update(
|
updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values,
|
|
||||||
null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) {
|
|
||||||
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The series anchor: every time-related column of the Events row. */
|
/** The series anchor: every time-related column of the Events row. */
|
||||||
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
eventUri(eventId),
|
||||||
arrayOf(
|
arrayOf(
|
||||||
CalendarContract.Events.DTSTART,
|
CalendarContract.Events.DTSTART,
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
@@ -1531,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time/recurrence set a series write carries together: the provider
|
||||||
|
* re-expands instances only from what the update itself holds.
|
||||||
|
*/
|
||||||
|
fun timeColumns(): ContentValues = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||||
|
put(CalendarContract.Events.RRULE, rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
|
||||||
|
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
|
||||||
|
existingExdate = exdate,
|
||||||
|
occurrences = occurrences,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = rrule,
|
||||||
|
rdate = rdate,
|
||||||
|
duration = duration,
|
||||||
|
timezone = timezone,
|
||||||
|
allDay = allDay,
|
||||||
|
)
|
||||||
|
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
beginMillis = beginMillis,
|
beginMillis = beginMillis,
|
||||||
@@ -1556,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
arrayOf(eventId.toString(), minutes.toString()),
|
arrayOf(eventId.toString(), minutes.toString()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(target - existing).forEach { minutes ->
|
insertReminderRows(eventId, (target - existing).toList())
|
||||||
val reminder = ContentValues().apply {
|
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
|
||||||
}
|
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
private fun EventAttendee.key(): String = emailKey(email)
|
||||||
private fun EventAttendee.key(): String = email.trim().lowercase()
|
|
||||||
|
|
||||||
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||||
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||||
@@ -1621,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val relationship: Int,
|
val relationship: Int,
|
||||||
val type: Int,
|
val type: Int,
|
||||||
) {
|
) {
|
||||||
val key: String get() = email.trim().lowercase()
|
val key: String get() = emailKey(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||||
@@ -1686,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
|
||||||
if (resolver.insert(
|
|
||||||
CalendarContract.Attendees.CONTENT_URI,
|
|
||||||
attendeeValues(eventId, attendee),
|
|
||||||
) == null
|
|
||||||
) {
|
|
||||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun eventUri(eventId: Long): Uri =
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
|
||||||
|
|
||||||
|
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
|
||||||
|
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
|
||||||
|
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
|
||||||
|
updateEventRow(eventId, values.toContentValues(), what)
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
ContentValues().also { cv ->
|
ContentValues().also { cv ->
|
||||||
forEach { (column, value) ->
|
forEach { (column, value) ->
|
||||||
@@ -1713,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun deleteEvent(eventId: Long) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
val deleted = resolver.delete(
|
val deleted = resolver.delete(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
eventUri(eventId),
|
||||||
null, null,
|
null, null,
|
||||||
)
|
)
|
||||||
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
||||||
@@ -1735,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||||
// Calendula's own contact special-date calendars are local and hold
|
// Calendula's own contact special-date calendars are local and hold
|
||||||
// yearly series, so this path is reached in normal use.
|
// yearly series, so this path is reached in normal use.
|
||||||
val values = buildOccurrenceExdateValues(
|
updateEventRow(
|
||||||
existingExdate = row.exdate,
|
eventId,
|
||||||
occurrenceMillis = beginMillis,
|
row.exdateValues(listOf(beginMillis)),
|
||||||
dtStartMillis = row.dtStartMillis,
|
"exdate occurrence event id=$eventId begin=$beginMillis",
|
||||||
rrule = row.rrule,
|
|
||||||
rdate = row.rdate,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
)
|
||||||
val updated = resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
if (updated == 0) {
|
|
||||||
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
@@ -1801,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Attendees.EVENT_ID + " = ?",
|
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||||
arrayOf(eventId.toString()),
|
arrayOf(eventId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
)?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
|
||||||
|
|
||||||
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||||
CalendarContract.Reminders.CONTENT_URI,
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
@@ -1809,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
arrayOf(eventId.toString()),
|
arrayOf(eventId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
/** Iterate every row and map; skips nothing. */
|
||||||
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
||||||
while (moveToNext()) add(mapper(this@mapAll))
|
while (moveToNext()) add(mapper(this@mapAll))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Iterate every row and map; drops nulls. */
|
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||||
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||||
private fun markerFor(type: SpecialDateType): String = when (type) {
|
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||||
@@ -1837,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
|
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||||
|
fun emailKey(email: String): String = email.trim().lowercase()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared account for every app-created local calendar, so they group
|
* Shared account for every app-created local calendar, so they group
|
||||||
* together (by account) in the filter sheet and calendar manager.
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface CalendarRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
* exposes none (see [CalendarDataSource.publishedEventColors]).
|
||||||
*/
|
*/
|
||||||
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
@@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis = range.start.toEpochMillis(),
|
beginMillis = range.start.toEpochMillis(),
|
||||||
endMillis = range.endInclusive.toEpochMillis(),
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
),
|
),
|
||||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
switchedOffCalendarIds = switchedOffCalendarIds(),
|
||||||
prefs.pendingDisabledCalendarIds.first(),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
@@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
.filterNot { it.isVisibleInSystem }
|
.filterNot { it.isVisibleInSystem }
|
||||||
.mapTo(mutableSetOf()) { it.id }
|
.mapTo(mutableSetOf()) { it.id }
|
||||||
|
|
||||||
|
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
|
||||||
|
private suspend fun switchedOffCalendarIds(): Set<Long> =
|
||||||
|
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
|
||||||
|
|
||||||
private val calendarsLock = Mutex()
|
private val calendarsLock = Mutex()
|
||||||
private var cachedGeneration = -1L
|
private var cachedGeneration = -1L
|
||||||
private var cachedPending: Set<Long>? = null
|
private var cachedPending: Set<Long>? = null
|
||||||
@@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() +
|
val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
|
||||||
prefs.pendingDisabledCalendarIds.first() +
|
|
||||||
invisibleCalendarIds()
|
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
|
||||||
|
|
||||||
override suspend fun createLocalCalendar(
|
override suspend fun createLocalCalendar(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
@@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||||
write {
|
write {
|
||||||
if (dataSource.canWriteCalendars()) {
|
val canWrite = dataSource.canWriteCalendars()
|
||||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||||
// Nothing of ours is left waiting for the provider once the
|
// Nothing of ours is left waiting for the provider once the write
|
||||||
// write lands (and switching one back on retires its entry).
|
// lands, and switching one back on retires its entry. With read-only
|
||||||
prefs.removePendingDisabledCalendarIds(ids)
|
// permission a switch-off still works, app-side, and the reconciler
|
||||||
} else if (visible) {
|
// flushes it if WRITE_CALENDAR ever arrives.
|
||||||
|
if (canWrite || visible) {
|
||||||
prefs.removePendingDisabledCalendarIds(ids)
|
prefs.removePendingDisabledCalendarIds(ids)
|
||||||
} else {
|
} else {
|
||||||
// Read-only permission: the switch still works, app-side, and
|
|
||||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
|
||||||
prefs.addPendingDisabledCalendarIds(ids)
|
prefs.addPendingDisabledCalendarIds(ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view over a single row's columns by index. Lets the mappers work
|
* Read-only view over a single row's columns by index. Lets the mappers work
|
||||||
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
|
|||||||
override fun getInt(index: Int): Int = cursor.getInt(index)
|
override fun getInt(index: Int): Int = cursor.getInt(index)
|
||||||
override fun isNull(index: Int): Boolean = cursor.isNull(index)
|
override fun isNull(index: Int): Boolean = cursor.isNull(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
|
||||||
|
|
||||||
|
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
|
||||||
|
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
|
||||||
|
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
|
||||||
|
|
||||||
|
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
|
||||||
|
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
|
||||||
|
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
|
||||||
|
|
||||||
|
/** Map every row, reading each through one [CursorColumnReader]. */
|
||||||
|
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
|
||||||
|
val reader = CursorColumnReader(this)
|
||||||
|
return buildList { while (moveToNext()) add(reader.map()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [mapRows], dropping the rows [map] returns null for. */
|
||||||
|
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
|
||||||
|
val reader = CursorColumnReader(this)
|
||||||
|
return buildList { while (moveToNext()) reader.map()?.let(::add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
|
||||||
|
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
|||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||||
// surface as the generic error screen with no way to open the event and fix
|
// surface as the generic error screen with no way to open the event and fix
|
||||||
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
|
||||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
|
||||||
} else {
|
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
// localized placeholder, and the edit form must prefill the true value.
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// The event's own colour (null = inherits the calendar's) is kept apart
|
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||||
// from the resolved display colour: the edit form needs to tell the two
|
// from the resolved display colour: the edit form needs to tell the two
|
||||||
// cases apart, while the instance carries the calendar fallback for display.
|
// cases apart, while the instance carries the calendar fallback for display.
|
||||||
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
|
||||||
null
|
|
||||||
} else {
|
|
||||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
|
||||||
}
|
|
||||||
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
||||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
// event's date is its UTC date.
|
// event's date is its UTC date.
|
||||||
val displayReminders = if (isAllDay) {
|
val displayReminders = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = utcDate(begin)
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
reminders.map {
|
reminders.map {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
reminders
|
reminders
|
||||||
}
|
}
|
||||||
|
|
||||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
|
||||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
|
||||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
|
||||||
EventStatus.Confirmed
|
|
||||||
} else {
|
|
||||||
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
|
||||||
}
|
|
||||||
|
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
instance = instance,
|
instance = instance,
|
||||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
reminders = displayReminders,
|
reminders = displayReminders,
|
||||||
status = status,
|
status = eventStatus(EventDetailProjection.IDX_STATUS),
|
||||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
// default these mappers already return — no isNull guard needed.
|
// default these mappers already return — no isNull guard needed.
|
||||||
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ internal fun exdateAfter(
|
|||||||
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
||||||
* a zoned one ([exdateListZone] reads that half).
|
* a zoned one ([exdateListZone] reads that half).
|
||||||
*/
|
*/
|
||||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
internal fun exdateStamps(exdate: String?): List<String> = exdate
|
||||||
?.withoutExdateParameters()
|
?.withoutExdateParameters()
|
||||||
?.split(',')
|
?.split(',')
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
@@ -879,7 +879,7 @@ internal fun buildImportedEventValues(
|
|||||||
*/
|
*/
|
||||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||||
|
|
||||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
// Unclamped: the writer renders DTEND as stored.
|
||||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
|
||||||
val end = when {
|
|
||||||
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
|
||||||
}
|
|
||||||
|
|
||||||
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
|
||||||
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
|
||||||
EventStatus.Confirmed
|
|
||||||
} else {
|
|
||||||
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
|
||||||
}
|
|
||||||
|
|
||||||
return IcsEvent(
|
return IcsEvent(
|
||||||
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||||
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
reminderMinutes = if (isAllDay) {
|
reminderMinutes = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = utcDate(dtStart)
|
||||||
reminderMinutes.map {
|
reminderMinutes.map {
|
||||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reminderMinutes
|
reminderMinutes
|
||||||
},
|
},
|
||||||
status = status,
|
status = eventStatus(EventExportProjection.IDX_STATUS),
|
||||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
)
|
)
|
||||||
@@ -112,11 +97,7 @@ internal fun exportExDates(
|
|||||||
): List<String> {
|
): List<String> {
|
||||||
if (exdate.isNullOrBlank()) return emptyList()
|
if (exdate.isNullOrBlank()) return emptyList()
|
||||||
val zone = exdateListZone(exdate, eventTimezone)
|
val zone = exdateListZone(exdate, eventTimezone)
|
||||||
return exdate
|
return exdateStamps(exdate)
|
||||||
.withoutExdateParameters()
|
|
||||||
.split(',')
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.map { stamp ->
|
.map { stamp ->
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
stamp.substringBefore('T')
|
stamp.substringBefore('T')
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
|
||||||
} else {
|
|
||||||
getInt(InstanceProjection.IDX_EVENT_COLOR)
|
|
||||||
}
|
|
||||||
|
|
||||||
return EventInstance(
|
return EventInstance(
|
||||||
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
|
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.net.Uri
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
|
|
||||||
|
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
|
||||||
|
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
|
||||||
|
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, beginMillis)
|
||||||
|
ContentUris.appendId(this, endMillis)
|
||||||
|
}.build()
|
||||||
|
|
||||||
internal object CalendarProjection {
|
internal object CalendarProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Calendars._ID,
|
CalendarContract.Calendars._ID,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||||
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
// long-running birthdays/anniversaries still surface in search.
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
val end = when {
|
val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
.coerceAtLeast(dtStart)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
|
||||||
}.coerceAtLeast(dtStart)
|
|
||||||
|
|
||||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
|
||||||
} else {
|
|
||||||
getInt(SearchProjection.IDX_EVENT_COLOR)
|
|
||||||
}
|
|
||||||
val eventId = getLong(SearchProjection.IDX_ID)
|
val eventId = getLong(SearchProjection.IDX_ID)
|
||||||
|
|
||||||
return EventInstance(
|
return EventInstance(
|
||||||
|
|||||||
+2
-5
@@ -1,9 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContentUris
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
|
|||||||
) : ReminderInstanceSource {
|
) : ReminderInstanceSource {
|
||||||
|
|
||||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = instancesUri(fromMillis, toMillis)
|
||||||
ContentUris.appendId(this, fromMillis)
|
|
||||||
ContentUris.appendId(this, toMillis)
|
|
||||||
}.build()
|
|
||||||
// `visible` is the flag the app's one visibility model writes (#75).
|
// `visible` is the flag the app's one visibility model writes (#75).
|
||||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||||
|
|||||||
@@ -148,23 +148,21 @@ enum class EventFormProblem {
|
|||||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||||
// anchor, not a location, so they never pin a zone.
|
// anchor, not a location, so they never pin a zone.
|
||||||
val pinnedZone = if (instance.isAllDay) {
|
val pinned = if (instance.isAllDay) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
eventTimezone
|
eventTimezone
|
||||||
?.takeIf { it != zone.id }
|
?.takeIf { it != zone.id }
|
||||||
// An unparseable id (a malformed sync row) can't be honoured or
|
// An unparseable id (a malformed sync row) can't be honoured or
|
||||||
// shown; fall back to the device zone rather than failing the open.
|
// shown; fall back to the device zone rather than failing the open.
|
||||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
?.let { id -> zoneOrNull(id)?.let { id to it } }
|
||||||
}
|
}
|
||||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
val pinnedZone = pinned?.first
|
||||||
|
val formZone = pinned?.second ?: zone
|
||||||
val begin = Instant.fromEpochMilliseconds(beginMillis)
|
val begin = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
val finish = Instant.fromEpochMilliseconds(endMillis)
|
val finish = Instant.fromEpochMilliseconds(endMillis)
|
||||||
val (start, end) = if (instance.isAllDay) {
|
val (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
|
allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
|
||||||
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
|
|
||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
|
||||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
|
||||||
} else {
|
} else {
|
||||||
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
||||||
}
|
}
|
||||||
@@ -180,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = rrule.normalizedRrule(),
|
||||||
// The provider fills EVENT_COLOR from the key, so [color] is the
|
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||||
// swatch either way; a null colour means the event inherits its
|
// swatch either way; a null colour means the event inherits its
|
||||||
// calendar's colour.
|
// calendar's colour.
|
||||||
@@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
rowEnd = instance.end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form times for an all-day span stored as UTC midnights with an exclusive
|
||||||
|
* end: the last covered day (never before [start]), with placeholder
|
||||||
|
* wall-clock times kept in case the user switches the event to timed.
|
||||||
|
*/
|
||||||
|
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
|
||||||
|
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
|
||||||
|
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare RRULE value the form edits; blank means none. */
|
||||||
|
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this form's times mean something else in [target] — a timed event
|
* Whether this form's times mean something else in [target] — a timed event
|
||||||
* pinned to another zone. False when there's nothing to disambiguate: an
|
* pinned to another zone. False when there's nothing to disambiguate: an
|
||||||
@@ -243,8 +254,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
*/
|
*/
|
||||||
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
|
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
|
||||||
if (isAllDay) return false
|
if (isAllDay) return false
|
||||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
|
return resolvedZone(target).id != target.id
|
||||||
return pinned.id != target.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -101,19 +101,13 @@ object EventSearch {
|
|||||||
|
|
||||||
val today = todayStart.toLocalDateTime(zone).date
|
val today = todayStart.toLocalDateTime(zone).date
|
||||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
|
||||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
// What is left reads forwards; what is behind us reads backwards from
|
||||||
.thenComparator { a, b ->
|
// today, nearest first.
|
||||||
// What is left reads forwards; what is behind us reads backwards
|
val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
|
||||||
// from today, nearest first.
|
past.sortedByDescending { it.event.dayStart(zone) }
|
||||||
if (upcoming(a)) {
|
|
||||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
|
||||||
} else {
|
|
||||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
|
||||||
return SearchResults(
|
return SearchResults(
|
||||||
months = groupByMonth(dated, zone, todayStart),
|
months = groupByMonth(dated, zone, todayStart),
|
||||||
inDescriptions = inDescriptions.map { it.hit },
|
inDescriptions = inDescriptions.map { it.hit },
|
||||||
@@ -126,33 +120,18 @@ object EventSearch {
|
|||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
todayStart: Instant,
|
todayStart: Instant,
|
||||||
): List<SearchMonth> {
|
): List<SearchMonth> {
|
||||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
|
||||||
return dated
|
val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
|
||||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
val (behind, ahead) = groups.keys.partition { it < current }
|
||||||
.entries
|
return (ahead.sorted() + behind.sortedDescending()).map { month ->
|
||||||
.sortedWith(
|
SearchMonth(
|
||||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
year = month.year,
|
||||||
when {
|
monthNumber = month.month.number,
|
||||||
it.key == currentKey -> 0
|
hits = groups.getValue(month).map { it.hit },
|
||||||
it.key > currentKey -> 1
|
|
||||||
else -> 2
|
|
||||||
}
|
|
||||||
}.thenComparator { a, b ->
|
|
||||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.map { (key, entries) ->
|
}
|
||||||
SearchMonth(
|
|
||||||
year = key / 12,
|
|
||||||
monthNumber = key % 12 + 1,
|
|
||||||
hits = entries.map { it.hit },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Year and month as one comparable number. */
|
|
||||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
|
||||||
|
|
||||||
private fun match(
|
private fun match(
|
||||||
candidate: SearchCandidate,
|
candidate: SearchCandidate,
|
||||||
tokens: List<String>,
|
tokens: List<String>,
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import kotlin.time.Instant
|
|||||||
import kotlin.time.toJavaInstant
|
import kotlin.time.toJavaInstant
|
||||||
import kotlin.time.toKotlinInstant
|
import kotlin.time.toKotlinInstant
|
||||||
|
|
||||||
|
/** [id] as a zone, or null when the tz database doesn't know it. */
|
||||||
|
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The zone this form's wall-clock times mean, matching what the data layer
|
* The zone this form's wall-clock times mean, matching what the data layer
|
||||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||||
*/
|
*/
|
||||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
timezone?.let(::zoneOrNull) ?: deviceZone
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
|
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
@@ -35,17 +32,11 @@ fun buildInsertEventForm(
|
|||||||
now: Instant,
|
now: Instant,
|
||||||
): EventForm {
|
): EventForm {
|
||||||
val (start, end) = if (isAllDay) {
|
val (start, end) = if (isAllDay) {
|
||||||
// All-day provider times are UTC midnights with an exclusive end; show
|
|
||||||
// the last covered day and keep placeholder wall-clock times in case the
|
|
||||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
|
||||||
val startDate = beginMillis
|
val startDate = beginMillis
|
||||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
?: now.toLocalDateTime(zone).date
|
?: now.toLocalDateTime(zone).date
|
||||||
val endDate = endMillis
|
val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
allDayFormTimes(startDate, endExclusive)
|
||||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
|
||||||
?: startDate
|
|
||||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
|
||||||
} else {
|
} else {
|
||||||
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||||
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||||
@@ -62,7 +53,7 @@ fun buildInsertEventForm(
|
|||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||||
// some callers include.
|
// some callers include.
|
||||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = rrule.normalizedRrule(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
|
|||||||
rrule: String,
|
rrule: String,
|
||||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
): SimpleRecurrence? {
|
): SimpleRecurrence? {
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';')
|
val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.associate { token ->
|
|
||||||
val eq = token.indexOf('=')
|
|
||||||
if (eq <= 0) return null
|
|
||||||
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
|
||||||
}
|
|
||||||
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val freq = when (parts["FREQ"]?.uppercase()) {
|
val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
|
||||||
"DAILY" -> RecurrenceFreq.Daily
|
?: return null
|
||||||
"WEEKLY" -> RecurrenceFreq.Weekly
|
|
||||||
"MONTHLY" -> RecurrenceFreq.Monthly
|
|
||||||
"YEARLY" -> RecurrenceFreq.Yearly
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||||
|
|
||||||
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||||
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
|||||||
when (val e = end) {
|
when (val e = end) {
|
||||||
RecurrenceEnd.Never -> Unit
|
RecurrenceEnd.Never -> Unit
|
||||||
is RecurrenceEnd.Until -> {
|
is RecurrenceEnd.Until -> {
|
||||||
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
append(";UNTIL=")
|
||||||
.toInstant(zone)
|
append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
|
||||||
.toLocalDateTime(TimeZone.UTC)
|
|
||||||
append(
|
|
||||||
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
|
||||||
utc.year, utc.month.number, utc.day,
|
|
||||||
utc.hour, utc.minute, utc.second,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
|
||||||
|
internal class RrulePart(val raw: String) {
|
||||||
|
private val eq = raw.indexOf('=')
|
||||||
|
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
|
||||||
|
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
|
||||||
|
* with a malformed one (reject, repair or skip) is the caller's call.
|
||||||
|
*/
|
||||||
|
internal fun rruleParts(rule: String): List<RrulePart> =
|
||||||
|
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
|
||||||
|
|
||||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
DayOfWeek.MONDAY to "MO",
|
DayOfWeek.MONDAY to "MO",
|
||||||
DayOfWeek.TUESDAY to "TU",
|
DayOfWeek.TUESDAY to "TU",
|
||||||
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
|||||||
val key = part.substringBefore('=').trim().uppercase()
|
val key = part.substringBefore('=').trim().uppercase()
|
||||||
key == "UNTIL" || key == "COUNT"
|
key == "UNTIL" || key == "COUNT"
|
||||||
}
|
}
|
||||||
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
|
||||||
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
|
||||||
until.year, until.month.number, until.day,
|
|
||||||
until.hour, until.minute, until.second,
|
|
||||||
)
|
|
||||||
return (kept + untilPart).joinToString(";")
|
return (kept + untilPart).joinToString(";")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
|||||||
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||||
* a [toRRule]-rendered value round-trips to the day the user picked.
|
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||||
*/
|
*/
|
||||||
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
|
||||||
val date = LocalDate(
|
val date = parseBasicDate(raw) ?: return null
|
||||||
raw.substring(0, 4).toInt(),
|
if (raw.length < 15 || raw[8] != 'T') return date
|
||||||
raw.substring(4, 6).toInt(),
|
return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
|
||||||
raw.substring(6, 8).toInt(),
|
}
|
||||||
)
|
|
||||||
if (raw.length >= 15 && raw[8] == 'T') {
|
|
||||||
val time = LocalTime(
|
|
||||||
raw.substring(9, 11).toInt(),
|
|
||||||
raw.substring(11, 13).toInt(),
|
|
||||||
raw.substring(13, 15).toInt(),
|
|
||||||
)
|
|
||||||
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
|
||||||
} else {
|
|
||||||
date
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
|
|||||||
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
|
|||||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||||
if (oldStart == newStart) return rrule
|
if (oldStart == newStart) return rrule
|
||||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
val parts = rruleParts(rrule)
|
||||||
if (parts.isEmpty()) return null
|
if (parts.isEmpty()) return null
|
||||||
|
var freq = false
|
||||||
var weekly = false
|
var weekly = false
|
||||||
|
var byDay = false
|
||||||
val rebuilt = parts.map { part ->
|
val rebuilt = parts.map { part ->
|
||||||
val eq = part.indexOf('=')
|
val value = part.value.trim()
|
||||||
if (eq <= 0) return null
|
when (part.key) {
|
||||||
val key = part.substring(0, eq).uppercase()
|
|
||||||
val value = part.substring(eq + 1).trim()
|
|
||||||
when (key) {
|
|
||||||
"FREQ" -> {
|
"FREQ" -> {
|
||||||
|
freq = true
|
||||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||||
part
|
part.raw
|
||||||
}
|
}
|
||||||
"BYDAY" -> {
|
"BYDAY" -> {
|
||||||
|
byDay = true
|
||||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||||
if (!value.equals(old, ignoreCase = true)) return null
|
if (!value.equals(old, ignoreCase = true)) return null
|
||||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||||
}
|
}
|
||||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
|
||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
if (!freq || (byDay && !weekly)) return null
|
||||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
|
||||||
return prefix + rebuilt.joinToString(";")
|
return prefix + rebuilt.joinToString(";")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.format
|
||||||
|
import kotlinx.datetime.format.char
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
|
||||||
|
private val BASIC_DATE_TIME = LocalDateTime.Format {
|
||||||
|
date(LocalDate.Formats.ISO_BASIC)
|
||||||
|
char('T')
|
||||||
|
hour()
|
||||||
|
minute()
|
||||||
|
second()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `yyyyMMdd`. */
|
||||||
|
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
|
||||||
|
|
||||||
|
/** `yyyyMMddTHHmmss`, floating. */
|
||||||
|
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
|
||||||
|
|
||||||
|
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
|
||||||
|
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date in the first eight characters of a basic-format value; whatever
|
||||||
|
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
|
||||||
|
*/
|
||||||
|
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
|
||||||
|
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = parseBasicDate(s) ?: return null
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import kotlinx.datetime.LocalDate
|
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
|
||||||
private const val DAY_MILLIS = 86_400_000L
|
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
dtEnd: IcsDateTime?,
|
dtEnd: IcsDateTime?,
|
||||||
duration: String?,
|
duration: String?,
|
||||||
): Instant {
|
): Instant {
|
||||||
val explicit = dtEnd?.instant ?: duration?.let {
|
val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
|
||||||
Instant.fromEpochMilliseconds(
|
|
||||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!start.isAllDay) return explicit ?: start.instant
|
if (!start.isAllDay) return explicit ?: start.instant
|
||||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
*/
|
*/
|
||||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||||
if (lines.isEmpty()) return emptyList()
|
if (lines.isEmpty()) return emptyList()
|
||||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
|
||||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||||
return lines
|
return lines
|
||||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||||
.mapNotNull { (line, token) ->
|
.mapNotNull { (line, token) ->
|
||||||
when {
|
when {
|
||||||
token.isEmpty() -> null
|
token.isEmpty() -> null
|
||||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
|
||||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
|
||||||
else -> parseBasicDate(token)
|
else -> parseBasicDate(token)
|
||||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||||
val zone = when {
|
val zone = when {
|
||||||
token.endsWith("Z") -> TimeZone.UTC
|
token.endsWith("Z") -> TimeZone.UTC
|
||||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
else -> line.params["TZID"]?.let(::zoneOrNull)
|
||||||
?: startZone
|
?: startZone
|
||||||
}
|
}
|
||||||
return ldt.toInstant(zone)
|
return ldt.toInstant(zone)
|
||||||
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
val tzid = line.params["TZID"]
|
val tzid = line.params["TZID"]
|
||||||
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
|
||||||
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
@@ -421,16 +415,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
fun IcsContentLine.isBegin(component: String) =
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
name == "BEGIN" && value.trim().equals(component, true)
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
fun Instant.plusDays(days: Int): Instant =
|
|
||||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
|
||||||
|
|
||||||
fun dayCode(date: LocalDate): String =
|
|
||||||
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
|
||||||
|
|
||||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
|
||||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||||
* for the source calendar's name. A multi-item list is a tag list and
|
* for the source calendar's name. A multi-item list is a tag list and
|
||||||
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
|
||||||
/**
|
/**
|
||||||
* Where an unterminated component's body has to stop: the next top-level
|
* Where an unterminated component's body has to stop: the next top-level
|
||||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||||
* nested `VALARM` is part of the body and is not a boundary.
|
* nested `VALARM` is part of the body and is not a boundary.
|
||||||
*/
|
*/
|
||||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
|
||||||
var i = from
|
(from until end).firstOrNull { i ->
|
||||||
while (i < end) {
|
|
||||||
val line = parseContentLine(lines[i])
|
val line = parseContentLine(lines[i])
|
||||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
|
||||||
return i
|
} ?: end
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return end
|
|
||||||
}
|
|
||||||
|
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
var i = from
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
|
||||||
while (i < lines.size) {
|
(from until lines.size).firstOrNull { i ->
|
||||||
val line = parseContentLine(lines[i])
|
val line = parseContentLine(lines[i])
|
||||||
if (line != null && line.name == "END" &&
|
line != null && line.name == "END" && line.value.trim().equals(component, true)
|
||||||
line.value.trim().equals(component, true)
|
} ?: lines.size
|
||||||
) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return lines.size
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
fun mapIcsStatus(value: String): EventStatus =
|
||||||
"TENTATIVE" -> EventStatus.Tentative
|
EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
|
||||||
"CANCELLED" -> EventStatus.Cancelled
|
?: EventStatus.Confirmed
|
||||||
else -> EventStatus.Confirmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
|
||||||
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
|
||||||
val date = LocalDate(
|
|
||||||
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
|
||||||
)
|
|
||||||
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
|
||||||
val time = LocalTime(
|
|
||||||
s.substring(9, 11).toInt(),
|
|
||||||
s.substring(11, 13).toInt(),
|
|
||||||
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
|
||||||
)
|
|
||||||
LocalDateTime(date, time)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.ics
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.rruleParts
|
||||||
|
|
||||||
private val VALID_FREQ = setOf(
|
private val VALID_FREQ = setOf(
|
||||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||||
)
|
)
|
||||||
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
|||||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
for (part in rule.split(';')) {
|
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
// being dropped, so it falls through and marks the rule repaired.
|
||||||
// being dropped, so it falls through and marks the rule repaired.
|
for (part in rruleParts(raw.orEmpty().trim())) {
|
||||||
if (part.isBlank()) continue
|
val key = part.key?.trim().orEmpty()
|
||||||
val key = part.substringBefore('=', "").trim().uppercase()
|
val value = part.value.trim()
|
||||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
|
||||||
val kept = when {
|
val kept = when {
|
||||||
key.isEmpty() || value.isEmpty() -> null
|
key.isEmpty() || value.isEmpty() -> null
|
||||||
key == "FREQ" ->
|
key == "FREQ" ->
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import kotlinx.datetime.LocalDateTime
|
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||||
add("BEGIN:VEVENT")
|
add("BEGIN:VEVENT")
|
||||||
add("UID:${event.uid}")
|
add("UID:${event.uid}")
|
||||||
add("DTSTAMP:${utcStamp(dtStamp)}")
|
add("DTSTAMP:${dtStamp.toUtcBasic()}")
|
||||||
add("SUMMARY:${escapeText(event.summary)}")
|
add("SUMMARY:${escapeText(event.summary)}")
|
||||||
appendTimes(event)
|
appendTimes(event)
|
||||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
|
|
||||||
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||||
event.isAllDay -> {
|
event.isAllDay -> {
|
||||||
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
|
||||||
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
|
||||||
}
|
}
|
||||||
// Recurring: anchor to wall-clock in the event's own zone.
|
// Recurring: anchor to wall-clock in the event's own zone.
|
||||||
event.recurrenceRule?.isNotBlank() == true -> {
|
event.recurrenceRule?.isNotBlank() == true -> {
|
||||||
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
val zone = zoneOrNull(event.zoneId)
|
||||||
if (zone != null) {
|
if (zone != null) {
|
||||||
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
|
||||||
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
|
||||||
} else {
|
} else {
|
||||||
// Unknown zone id → fall back to plain UTC instants.
|
// Unknown zone id → fall back to plain UTC instants.
|
||||||
add("DTSTART:${utcStamp(event.start)}")
|
add("DTSTART:${event.start.toUtcBasic()}")
|
||||||
add("DTEND:${utcStamp(event.end)}")
|
add("DTEND:${event.end.toUtcBasic()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
add("DTSTART:${utcStamp(event.start)}")
|
add("DTSTART:${event.start.toUtcBasic()}")
|
||||||
add("DTEND:${utcStamp(event.end)}")
|
add("DTEND:${event.end.toUtcBasic()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||||
fun triggerValue(minutes: Int): String =
|
fun triggerValue(minutes: Int): String =
|
||||||
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||||
|
|
||||||
fun utcStamp(instant: Instant): String =
|
|
||||||
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
|
||||||
|
|
||||||
fun localStamp(instant: Instant, zone: TimeZone): String =
|
|
||||||
basic(instant.toLocalDateTime(zone))
|
|
||||||
|
|
||||||
fun utcDate(instant: Instant): String {
|
|
||||||
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
|
||||||
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
|
||||||
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.ics
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
||||||
|
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
||||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
@@ -23,10 +21,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
*/
|
*/
|
||||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
val (start, end) = if (isAllDay) {
|
val (start, end) = if (isAllDay) {
|
||||||
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
|
||||||
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
|
||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
|
||||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
|
||||||
} else {
|
} else {
|
||||||
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
||||||
}
|
}
|
||||||
@@ -40,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = semanticReminderMinutes(),
|
reminders = semanticReminderMinutes(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = recurrenceRule.normalizedRrule(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.yearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||||
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
|
|||||||
DAYS_PER_WEEK - sinceWeekStart
|
DAYS_PER_WEEK - sinceWeekStart
|
||||||
}
|
}
|
||||||
AgendaRange.ThisMonth -> {
|
AgendaRange.ThisMonth -> {
|
||||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
val daysInMonth = anchor.yearMonth.numberOfDays
|
||||||
daysInMonth - anchor.day + 1
|
daysInMonth - anchor.day + 1
|
||||||
}
|
}
|
||||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||||
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
|
|||||||
locale: Locale,
|
locale: Locale,
|
||||||
monthAsSpan: Boolean = false,
|
monthAsSpan: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
val javaStart = start.toJavaLocalDate()
|
||||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
val javaEnd = end.toJavaLocalDate()
|
||||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -9,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
@@ -30,22 +34,25 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlin.time.Instant
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||||
@@ -176,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
|
|||||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
is AgendaTimeLabel.Starts -> stringResource(
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
R.string.agenda_span_starts,
|
R.string.agenda_span_starts,
|
||||||
formatTime(label.start, zone, is24Hour, locale),
|
formatTimeOfDay(label.start, zone, is24Hour, locale),
|
||||||
)
|
)
|
||||||
is AgendaTimeLabel.Ends -> stringResource(
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
R.string.agenda_span_ends,
|
R.string.agenda_span_ends,
|
||||||
formatTime(label.end, zone, is24Hour, locale),
|
formatTimeOfDay(label.end, zone, is24Hour, locale),
|
||||||
)
|
)
|
||||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
|
||||||
formatTime(label.end, zone, is24Hour, locale)
|
formatTimeOfDay(label.end, zone, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(
|
|
||||||
instant: Instant,
|
|
||||||
zone: TimeZone,
|
|
||||||
is24Hour: Boolean,
|
|
||||||
locale: Locale,
|
|
||||||
): String {
|
|
||||||
val t = instant.toLocalDateTime(zone).time
|
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatAgendaDate(date: LocalDate): String {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = date.toJavaLocalDate()
|
||||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agenda's day-by-day list body: a sticky header per day, its events or a
|
||||||
|
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
|
||||||
|
* and the month view's split pane so both lists read the same.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal fun LazyListScope.agendaDays(
|
||||||
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
@StringRes emptyText: Int,
|
||||||
|
onEmptyClick: (LocalDate) -> Unit,
|
||||||
|
dimmed: (EventInstance, LocalDate) -> Boolean,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
|
stickyHeader(key = "header-${day.date}") {
|
||||||
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
|
}
|
||||||
|
if (day.events.isEmpty()) {
|
||||||
|
item(key = "placeholder-${day.date}") {
|
||||||
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(emptyText),
|
||||||
|
onClick = { onEmptyClick(day.date) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsIndexed(
|
||||||
|
items = day.events,
|
||||||
|
// Scope the key by day: a multi-day event appears under every
|
||||||
|
// day it spans, so its instanceId alone is not unique across
|
||||||
|
// the list (LazyColumn requires unique keys).
|
||||||
|
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimmed(event, day.date),
|
||||||
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -26,7 +23,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
@@ -66,11 +62,9 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -379,7 +373,6 @@ internal fun AgendaContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
@@ -444,39 +437,17 @@ private fun AgendaList(
|
|||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
agendaDays(
|
||||||
stickyHeader(key = "header-${day.date}") {
|
days = days,
|
||||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
today = today,
|
||||||
}
|
zone = zone,
|
||||||
if (day.events.isEmpty()) {
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
// An anchored, event-less today (#35) — "nothing left today".
|
emptyText = R.string.agenda_no_more_today,
|
||||||
item(key = "placeholder-${day.date}") {
|
onEmptyClick = onOpenDay,
|
||||||
AgendaEmptyDayRow(
|
dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
|
||||||
text = stringResource(R.string.agenda_no_more_today),
|
onOpenDay = onOpenDay,
|
||||||
onClick = { onOpenDay(day.date) },
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
itemsIndexed(
|
|
||||||
items = day.events,
|
|
||||||
// Scope the key by day: a multi-day event appears under every
|
|
||||||
// day it spans, so its instanceId alone is not unique across
|
|
||||||
// the list (LazyColumn requires unique keys).
|
|
||||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
|
||||||
) { index, event ->
|
|
||||||
AgendaEventRow(
|
|
||||||
event = event,
|
|
||||||
day = day.date,
|
|
||||||
zone = zone,
|
|
||||||
position = positionOf(index, day.events.size),
|
|
||||||
dimmed = dimPast && event.isPastOn(day.date, now, zone),
|
|
||||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
|
||||||
onClick = { onEventClick(event) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -31,11 +32,8 @@ import kotlinx.datetime.DateTimeUnit
|
|||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -243,8 +241,5 @@ class AgendaViewModel @Inject constructor(
|
|||||||
internal const val PAST_CHUNK_DAYS = 30
|
internal const val PAST_CHUNK_DAYS = 30
|
||||||
|
|
||||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||||
val from = anchor.atStartOfDayIn(zone)
|
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||||
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
|
||||||
return from..to
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -317,7 +318,7 @@ private fun ExportCalendarPicker(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id in selected
|
val isSelected = calendar.id in selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, calendars.size),
|
position = positionOf(index, calendars.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
|
||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
@@ -238,21 +239,10 @@ private fun CalendarsList(
|
|||||||
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
|
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
CalendarManagerRow(
|
||||||
GroupedRow(
|
calendar = calendar,
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
last = index == local.lastIndex,
|
||||||
summary = calendarRowSummary(calendar),
|
onSetVisible = onSetVisible,
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
dimmed = disabled,
|
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
|
||||||
trailing = {
|
|
||||||
EnableSwitch(
|
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
|
||||||
enabled = !disabled,
|
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,27 +313,10 @@ private fun CalendarsList(
|
|||||||
// bottom, dimmed and switchless.
|
// bottom, dimmed and switchless.
|
||||||
val ordered = cals.orderedForManager()
|
val ordered = cals.orderedForManager()
|
||||||
ordered.forEachIndexed { index, calendar ->
|
ordered.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
CalendarManagerRow(
|
||||||
GroupedRow(
|
calendar = calendar,
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
last = index == ordered.lastIndex,
|
||||||
summary = calendarRowSummary(calendar),
|
onSetVisible = onSetVisible,
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
dimmed = disabled,
|
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
|
||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
|
||||||
{
|
|
||||||
EnableSwitch(
|
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
|
||||||
enabled = calendar.isVisibleInSystem,
|
|
||||||
onToggle = { enabled ->
|
|
||||||
onSetVisible(calendar.id, enabled)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
|
|||||||
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||||
* stops its reminders. Carries its own content description.
|
* stops its reminders. Carries its own content description.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* One calendar in a group: dimmed while switched off or not syncing, with the
|
||||||
|
* visibility switch only where it can change anything.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EnableSwitch(
|
private fun CalendarManagerRow(
|
||||||
calendarName: String,
|
calendar: CalendarSource,
|
||||||
enabled: Boolean,
|
last: Boolean,
|
||||||
onToggle: (Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
Switch(
|
GroupedRow(
|
||||||
checked = enabled,
|
title = calendar.displayName.orUnnamed(),
|
||||||
onCheckedChange = onToggle,
|
summary = calendarRowSummary(calendar),
|
||||||
modifier = Modifier.semantics { contentDescription = label },
|
position = if (last) Position.Bottom else Position.Middle,
|
||||||
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
|
{
|
||||||
|
CalendarVisibilitySwitch(
|
||||||
|
calendarName = calendar.displayName.orUnnamed(),
|
||||||
|
enabled = calendar.isVisibleInSystem,
|
||||||
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
val label = group.label.orUnnamed()
|
||||||
return if (!group.ambiguous) {
|
return if (!group.ambiguous) {
|
||||||
label
|
label
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ private fun CalendarPickerGroup(
|
|||||||
)
|
)
|
||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
checked = calendar.id == selectedId,
|
checked = calendar.id == selectedId,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.LineHeightStyle
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
||||||
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
|||||||
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A calendar or account name, or the "unnamed" label when it is blank. */
|
||||||
|
@Composable
|
||||||
|
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
|
||||||
|
@Composable
|
||||||
|
fun CalendarVisibilitySwitch(
|
||||||
|
calendarName: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
modifier = Modifier.semantics { contentDescription = label },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
|
||||||
|
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
|
||||||
|
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
|
||||||
|
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||||
|
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
return minus(offset, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||||
|
import de.jeanlucmakiola.calendula.domain.rruleParts
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
|
||||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
@@ -32,10 +34,7 @@ import java.util.Locale
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
|
||||||
val eq = token.indexOf('=')
|
|
||||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val freq = parts["FREQ"]?.uppercase()
|
val freq = parts["FREQ"]?.uppercase()
|
||||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||||
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
|
|||||||
.atOffset(ZoneOffset.UTC)
|
.atOffset(ZoneOffset.UTC)
|
||||||
.atZoneSameInstant(zone)
|
.atZoneSameInstant(zone)
|
||||||
.toLocalDate()
|
.toLocalDate()
|
||||||
}.recoverCatching {
|
}.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
|
||||||
val digits = value.takeWhile { it.isDigit() }
|
|
||||||
LocalDate.of(
|
|
||||||
digits.substring(0, 4).toInt(),
|
|
||||||
digits.substring(4, 6).toInt(),
|
|
||||||
digits.substring(6, 8).toInt(),
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlin.time.Instant
|
|||||||
import kotlin.time.toJavaInstant
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||||
@@ -37,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
|
|||||||
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||||
.format(timeOfDayFormatter(is24Hour, locale))
|
.format(timeOfDayFormatter(is24Hour, locale))
|
||||||
|
|
||||||
|
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
|
||||||
|
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
|
||||||
|
val time = instant.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||||
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import de.jeanlucmakiola.calendula.domain.RichText
|
|||||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
@@ -420,7 +422,7 @@ private fun EventDetailContent(
|
|||||||
// — which a cross-zone event easily does — carries the whole span
|
// — which a cross-zone event easily does — carries the whole span
|
||||||
// in the primary instead, so fall back to it. If the zone can't be
|
// in the primary instead, so fall back to it. If the zone can't be
|
||||||
// read back at all, drop to just the abbreviation + offset.
|
// read back at all, drop to just the abbreviation + offset.
|
||||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
val originalTime = zoneOrNull(zoneOption.id)
|
||||||
?.let { rememberWhen(instance, it, locale) }
|
?.let { rememberWhen(instance, it, locale) }
|
||||||
?.let { (primary, secondary) -> secondary ?: primary }
|
?.let { (primary, secondary) -> secondary ?: primary }
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
@@ -445,7 +447,7 @@ private fun EventDetailContent(
|
|||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
text = state.calendarName?.orUnnamed()
|
||||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -85,11 +88,9 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -832,7 +833,7 @@ private fun EventEditContent(
|
|||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.event_edit_managed_hint,
|
R.string.event_edit_managed_hint,
|
||||||
selectedCalendar?.displayName
|
selectedCalendar?.displayName
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
?.orUnnamed()
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -939,7 +940,7 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
?.orUnnamed()
|
||||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (selectedCalendar == null) {
|
color = if (selectedCalendar == null) {
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
@@ -43,6 +45,7 @@ fun CalendarFilterList(
|
|||||||
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
||||||
is FilterUiState.Success -> FilterList(
|
is FilterUiState.Success -> FilterList(
|
||||||
groups = s.groups,
|
groups = s.groups,
|
||||||
|
hidden = s.hidden,
|
||||||
onSetVisible = viewModel::setVisible,
|
onSetVisible = viewModel::setVisible,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -56,23 +59,15 @@ fun CalendarFilterList(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterList(
|
private fun FilterList(
|
||||||
groups: List<AccountGroup>,
|
groups: List<CalendarAccountGroup>,
|
||||||
|
hidden: Set<Long>,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
Text(
|
Text(
|
||||||
text = if (group.ambiguous) {
|
text = accountGroupTitle(group),
|
||||||
stringResource(
|
|
||||||
R.string.calendars_account_from_source,
|
|
||||||
account,
|
|
||||||
sourceAppName(group.accountType),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
account
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
@@ -83,18 +78,19 @@ private fun FilterList(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
|
val visible = cal.id !in hidden
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = cal.displayName.orUnnamed(),
|
||||||
position = positionOf(index, group.calendars.size),
|
position = positionOf(index, group.calendars.size),
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { CalendarColorChip(cal.color) },
|
leading = { CalendarColorChip(cal.color) },
|
||||||
trailing = {
|
trailing = {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = cal.visible,
|
checked = visible,
|
||||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onSetVisible(cal.id, !cal.visible) },
|
onClick = { onSetVisible(cal.id, !visible) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.filter
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||||
@@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
sealed interface FilterUiState {
|
sealed interface FilterUiState {
|
||||||
data object Loading : FilterUiState
|
data object Loading : FilterUiState
|
||||||
data class Failure(val reason: FailureReason) : FilterUiState
|
data class Failure(val reason: FailureReason) : FilterUiState
|
||||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
/** A calendar shows when its id is *not* in [hidden]. */
|
||||||
|
data class Success(
|
||||||
|
val groups: List<CalendarAccountGroup>,
|
||||||
|
val hidden: Set<Long>,
|
||||||
|
) : FilterUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
|
||||||
*
|
|
||||||
* [accountType] and [ambiguous] carry what the header needs to tell two
|
|
||||||
* same-named accounts from different apps apart (#77); the label itself is
|
|
||||||
* built in the UI layer, which is where the source app's name can be looked up.
|
|
||||||
*/
|
|
||||||
data class AccountGroup(
|
|
||||||
val account: String,
|
|
||||||
val accountType: String,
|
|
||||||
val ambiguous: Boolean,
|
|
||||||
val calendars: List<CalendarRow>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CalendarRow(
|
|
||||||
val id: Long,
|
|
||||||
val displayName: String,
|
|
||||||
val color: Int,
|
|
||||||
val visible: Boolean,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
@@ -39,7 +38,7 @@ class FilterViewModel @Inject constructor(
|
|||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
FilterUiState.Failure(failure)
|
FilterUiState.Failure(failure)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
FilterUiState.Success(enabled.groupByAccount(), hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -59,28 +58,3 @@ class FilterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Group calendars under their owning account — by name *and* type, so two
|
|
||||||
* accounts that merely share a name stay apart (#77) — preserving the
|
|
||||||
* provider's order within each group and ordering groups by first appearance.
|
|
||||||
* A calendar is "visible" when its id is *not* in [hidden].
|
|
||||||
*/
|
|
||||||
internal fun groupCalendarsForFilter(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
hidden: Set<Long>,
|
|
||||||
): List<AccountGroup> =
|
|
||||||
calendars.groupByAccount().map { group ->
|
|
||||||
AccountGroup(
|
|
||||||
account = group.label,
|
|
||||||
accountType = group.accountType,
|
|
||||||
ambiguous = group.ambiguous,
|
|
||||||
calendars = group.calendars.map { c ->
|
|
||||||
CalendarRow(
|
|
||||||
id = c.id,
|
|
||||||
displayName = c.displayName,
|
|
||||||
color = c.color,
|
|
||||||
visible = c.id !in hidden,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +22,7 @@ internal fun monthChipTime(
|
|||||||
locale: Locale,
|
locale: Locale,
|
||||||
): String? {
|
): String? {
|
||||||
if (event.isAllDay || continuesLeft) return null
|
if (event.isAllDay || continuesLeft) return null
|
||||||
val start = event.start.toLocalDateTime(zone).time
|
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||||
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState
|
|||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||||
@@ -66,7 +68,6 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -191,10 +192,13 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.datetime.toJavaMonth
|
||||||
|
import kotlinx.datetime.yearMonth
|
||||||
|
|
||||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||||
|
|
||||||
@@ -343,7 +347,7 @@ fun MonthScreen(
|
|||||||
if (dense) {
|
if (dense) {
|
||||||
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
||||||
.plus(3, DateTimeUnit.DAY)
|
.plus(3, DateTimeUnit.DAY)
|
||||||
YearMonth(midweek.year, midweek.month)
|
midweek.yearMonth
|
||||||
} else {
|
} else {
|
||||||
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
|
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
|
||||||
}
|
}
|
||||||
@@ -392,7 +396,7 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
val isOnCurrentMonth = titleMonth == today.yearMonth
|
||||||
var splitExpanded by rememberSaveable(viewStyle) {
|
var splitExpanded by rememberSaveable(viewStyle) {
|
||||||
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
||||||
}
|
}
|
||||||
@@ -421,7 +425,7 @@ fun MonthScreen(
|
|||||||
val jumpToToday: () -> Unit = {
|
val jumpToToday: () -> Unit = {
|
||||||
todayPulse++
|
todayPulse++
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
val targetMonth = YearMonth(today.year, today.month)
|
val targetMonth = today.yearMonth
|
||||||
pendingScrollMonth = targetMonth
|
pendingScrollMonth = targetMonth
|
||||||
val requestId = ++scrollRequestId
|
val requestId = ++scrollRequestId
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
@@ -439,13 +443,13 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
jumpToMonth(YearMonth(today.year, today.month))
|
jumpToMonth(today.yearMonth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drawer jump-to-date.
|
// Drawer jump-to-date.
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
val targetMonth = YearMonth(target.year, target.month)
|
val targetMonth = target.yearMonth
|
||||||
pendingScrollMonth = targetMonth
|
pendingScrollMonth = targetMonth
|
||||||
val requestId = ++scrollRequestId
|
val requestId = ++scrollRequestId
|
||||||
viewModel.goToDate(target)
|
viewModel.goToDate(target)
|
||||||
@@ -463,7 +467,7 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.goToDate(target)
|
viewModel.goToDate(target)
|
||||||
jumpToMonth(YearMonth(target.year, target.month))
|
jumpToMonth(target.yearMonth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +551,7 @@ fun MonthScreen(
|
|||||||
// still settling would land and take the tap back.
|
// still settling would land and take the tap back.
|
||||||
onSelectDay = { date ->
|
onSelectDay = { date ->
|
||||||
viewModel.selectDate(date)
|
viewModel.selectDate(date)
|
||||||
jumpToMonth(YearMonth(date.year, date.month))
|
jumpToMonth(date.yearMonth)
|
||||||
},
|
},
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -720,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = dow.toJavaDayOfWeek()
|
||||||
Text(
|
Text(
|
||||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -939,7 +943,7 @@ internal fun ContinuousMonthGrid(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val monthCount = remember { continuousMonthCount() }
|
val monthCount = remember { continuousMonthCount() }
|
||||||
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
val todayMonth = remember(state.today) { state.today.yearMonth }
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -1837,7 +1841,6 @@ private fun SplitDots(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthAgendaPane(
|
private fun SplitMonthAgendaPane(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
@@ -1855,33 +1858,16 @@ private fun SplitMonthAgendaPane(
|
|||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val listState = key(state.month) { rememberLazyListState() }
|
val listState = key(state.month) { rememberLazyListState() }
|
||||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
days.forEach { day ->
|
agendaDays(
|
||||||
val events = state.instancesByDay[day].orEmpty()
|
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||||
stickyHeader(key = "header-$day") {
|
today = state.today,
|
||||||
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
|
zone = state.zone,
|
||||||
}
|
emptyText = R.string.month_split_no_events,
|
||||||
if (events.isEmpty()) {
|
onEmptyClick = onCreateEvent,
|
||||||
item(key = "empty-$day") {
|
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||||
AgendaEmptyDayRow(
|
onOpenDay = onOpenDay,
|
||||||
text = stringResource(R.string.month_split_no_events),
|
onEventClick = onEventClick,
|
||||||
onClick = { onCreateEvent(day) },
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
|
|
||||||
AgendaEventRow(
|
|
||||||
event = event,
|
|
||||||
day = day,
|
|
||||||
zone = state.zone,
|
|
||||||
position = positionOf(index, events.size),
|
|
||||||
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
|
||||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
|
||||||
onClick = { onEventClick(event) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2715,7 +2701,7 @@ private fun DayNumberCell(
|
|||||||
private fun shortMonthName(date: LocalDate): String {
|
private fun shortMonthName(date: LocalDate): String {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
return remember(date.month, locale) {
|
return remember(date.month, locale) {
|
||||||
java.time.Month.of(date.month.ordinal + 1)
|
date.month.toJavaMonth()
|
||||||
.getDisplayName(JavaTextStyle.SHORT, locale)
|
.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2878,7 +2864,7 @@ private fun formatMonthTitle(
|
|||||||
abbreviated: Boolean = false,
|
abbreviated: Boolean = false,
|
||||||
): String =
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
date = ym.firstDay.toJavaLocalDate(),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import kotlinx.datetime.DateTimeUnit
|
|||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlinx.datetime.yearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
|
|||||||
state = sample.continuous,
|
state = sample.continuous,
|
||||||
listState = rememberLazyListState(
|
listState = rememberLazyListState(
|
||||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
monthIndexOf(YearMonth(today.year, today.month)),
|
monthIndexOf(today.yearMonth),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
@@ -109,7 +109,7 @@ private fun sampleMonthState(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): SampleMonth {
|
): SampleMonth {
|
||||||
val ym = YearMonth(today.year, today.month)
|
val ym = today.yearMonth
|
||||||
val first = LocalDate(ym.year, ym.month, 1)
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
val events = sampleMonthEvents(first, today, zone)
|
val events = sampleMonthEvents(first, today, zone)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -39,15 +41,13 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.Month
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.daysUntil
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.datetime.yearMonth
|
||||||
|
|
||||||
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
||||||
private const val MONTH_CACHE_SPAN = 2
|
private const val MONTH_CACHE_SPAN = 2
|
||||||
@@ -101,7 +101,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||||
|
|
||||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,11 +182,11 @@ class MonthViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Last reported visible range, so a widening step can re-centre on it. */
|
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
private var visibleMonths = monthIndexOf(todayDate.yearMonth)
|
||||||
.let { it..it }
|
.let { it..it }
|
||||||
|
|
||||||
private val _loadedMonths = MutableStateFlow(
|
private val _loadedMonths = MutableStateFlow(
|
||||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
monthIndexOf(todayDate.yearMonth)
|
||||||
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,13 +202,11 @@ class MonthViewModel @Inject constructor(
|
|||||||
// Widened to whole grid weeks at both ends: a month block still
|
// Widened to whole grid weeks at both ends: a month block still
|
||||||
// has to know about an event that starts in the boundary week's
|
// has to know about an event that starts in the boundary week's
|
||||||
// clipped-off days, or a bar running into the block would vanish.
|
// clipped-off days, or a bar running into the block would vanish.
|
||||||
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
|
||||||
val last = firstOfMonth(yearMonthForIndex(window.last))
|
val last = yearMonthForIndex(window.last).lastDay
|
||||||
.plus(1, DateTimeUnit.MONTH)
|
.startOfWeek(ws)
|
||||||
.minus(1, DateTimeUnit.DAY)
|
|
||||||
.startOfGridWeek(ws)
|
|
||||||
.plus(6, DateTimeUnit.DAY)
|
.plus(6, DateTimeUnit.DAY)
|
||||||
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
val range = dayRange(first, last, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(range),
|
||||||
@@ -306,7 +304,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
* silently list a date the grid isn't pointing at.
|
* silently list a date the grid isn't pointing at.
|
||||||
*/
|
*/
|
||||||
fun selectDate(date: LocalDate) {
|
fun selectDate(date: LocalDate) {
|
||||||
val target = YearMonth(date.year, date.month)
|
val target = date.yearMonth
|
||||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||||
if (target != _month.value) _month.value = target
|
if (target != _month.value) _month.value = target
|
||||||
}
|
}
|
||||||
@@ -321,12 +319,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||||
_selectedDate.value = next
|
_selectedDate.value = next
|
||||||
_month.value = YearMonth(next.year, next.month)
|
_month.value = next.yearMonth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = todayDate.yearMonth
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,8 +335,8 @@ class MonthViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||||
if (viewStyle.value.isScrolling) return
|
if (viewStyle.value.isScrolling) return
|
||||||
if (_month.value != YearMonth(old.year, old.month)) return
|
if (_month.value != old.yearMonth) return
|
||||||
_month.value = YearMonth(new.year, new.month)
|
_month.value = new.yearMonth
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +356,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = date.yearMonth
|
||||||
_selectedDate.value = date
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +406,7 @@ internal fun layoutMonthWeeks(
|
|||||||
byDay: DayIndex,
|
byDay: DayIndex,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<MonthWeek> {
|
): List<MonthWeek> {
|
||||||
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
val gridStart = ym.firstDay.startOfWeek(weekStart)
|
||||||
val weekCount = weekRowsInMonth(ym, weekStart)
|
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
@@ -529,12 +527,8 @@ internal fun monthGridRange(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): ClosedRange<Instant> {
|
): ClosedRange<Instant> {
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
val gridStart = ym.firstDay.startOfWeek(weekStart)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
|
||||||
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
|
|
||||||
val start = gridStart.atStartOfDayIn(zone)
|
|
||||||
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
|
|
||||||
return start..end
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -562,7 +556,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
|
|||||||
* the first of the visible month.
|
* the first of the visible month.
|
||||||
*/
|
*/
|
||||||
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
if (YearMonth(today.year, today.month) == month) {
|
if (today.yearMonth == month) {
|
||||||
today
|
today
|
||||||
} else {
|
} else {
|
||||||
LocalDate(month.year, month.month, 1)
|
LocalDate(month.year, month.month, 1)
|
||||||
@@ -630,8 +624,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
|
|||||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||||
* blocks to key by, and a row's identity has to survive the months around it
|
* blocks to key by, and a row's identity has to survive the months around it
|
||||||
@@ -646,12 +638,12 @@ private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
|||||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||||
|
|
||||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||||
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
||||||
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
return base.daysUntil(date.startOfWeek(weekStart)) / 7
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||||
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||||
@@ -662,7 +654,7 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
|||||||
// The row's midweek day: a boundary week is reported as whichever month owns
|
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||||
// most of it, the same rule the title uses.
|
// most of it, the same rule the title uses.
|
||||||
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||||
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
return monthIndexOf(midweek.yearMonth)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -671,19 +663,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
|||||||
* laid out too, rather than flickering as placeholders.
|
* laid out too, rather than flickering as placeholders.
|
||||||
*/
|
*/
|
||||||
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||||
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
|
||||||
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
val lastMonthEnd = yearMonthForIndex(window.last).lastDay
|
||||||
.plus(1, DateTimeUnit.MONTH)
|
|
||||||
.minus(1, DateTimeUnit.DAY)
|
|
||||||
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||||
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||||
val first = firstOfMonth(ym)
|
val first = ym.firstDay
|
||||||
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
return (leadOffset + ym.numberOfDays + 6) / 7
|
||||||
return (leadOffset + daysInMonth + 6) / 7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -727,9 +716,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
|||||||
countByDay = week.countByDay.filterKeys { it in own },
|
countByDay = week.countByDay.filterKeys { it in own },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
return minus(offset, DateTimeUnit.DAY)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,20 +8,19 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||||
@@ -122,7 +121,7 @@ internal fun HiddenCalendarsStep(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val off = !calendar.isVisibleInSystem
|
val off = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
||||||
position = when {
|
position = when {
|
||||||
calendars.size == 1 -> Position.Alone
|
calendars.size == 1 -> Position.Alone
|
||||||
@@ -133,8 +132,8 @@ internal fun HiddenCalendarsStep(
|
|||||||
dimmed = off,
|
dimmed = off,
|
||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
{
|
{
|
||||||
CalendarSwitch(
|
CalendarVisibilitySwitch(
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
calendarName = calendar.displayName.orUnnamed(),
|
||||||
enabled = !off,
|
enabled = !off,
|
||||||
onToggle = { on -> onSetVisible(calendar.id, on) },
|
onToggle = { on -> onSetVisible(calendar.id, on) },
|
||||||
)
|
)
|
||||||
@@ -149,18 +148,3 @@ internal fun HiddenCalendarsStep(
|
|||||||
Spacer(Modifier.height(OnboardingSpace.md))
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
|
|
||||||
@Composable
|
|
||||||
private fun CalendarSwitch(
|
|
||||||
calendarName: String,
|
|
||||||
enabled: Boolean,
|
|
||||||
onToggle: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
|
||||||
Switch(
|
|
||||||
checked = enabled,
|
|
||||||
onCheckedChange = onToggle,
|
|
||||||
modifier = Modifier.semantics { contentDescription = label },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
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.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
@@ -129,7 +130,7 @@ internal fun EventFormScreen(
|
|||||||
durationCalendars.forEachIndexed { index, calendar ->
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
val override = state.perCalendarEventDuration[calendar.id]
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
summary = override?.let { durationLabel(it) }
|
summary = override?.let { durationLabel(it) }
|
||||||
?: stringResource(
|
?: stringResource(
|
||||||
R.string.settings_calendar_duration_inherits,
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.android.hasPermission
|
import de.jeanlucmakiola.floret.android.hasPermission
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||||
@@ -172,7 +173,7 @@ internal fun NotificationsScreen(
|
|||||||
// own section — link there instead.
|
// own section — link there instead.
|
||||||
if (calendar.id in state.managedCalendarIds) {
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
@@ -189,7 +190,7 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName.orUnnamed(),
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = { ExpandIcon(expanded) },
|
trailing = { ExpandIcon(expanded) },
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Views: everything that changes how a calendar view reads, grouped by the view
|
* Views: everything that changes how a calendar view reads, grouped by the view
|
||||||
@@ -504,7 +505,7 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
|||||||
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||||
// Localised weekday name, so no per-day string resource is needed.
|
// Localised weekday name, so no per-day string resource is needed.
|
||||||
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
is WeekStartPref.Day -> pref.day.toJavaDayOfWeek()
|
||||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|||||||
@@ -202,10 +202,12 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.datetime.yearMonth
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||||
@@ -1084,7 +1086,7 @@ private fun WeekDayHeader(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
||||||
) { date ->
|
) { date ->
|
||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = date.dayOfWeek.toJavaDayOfWeek()
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1958,7 +1960,7 @@ private fun formatWeekTitle(
|
|||||||
abbreviated: Boolean = false,
|
abbreviated: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
return formatCalendarTitle(
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = weekStart.yearMonth.firstDay.toJavaLocalDate(),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
@@ -1974,7 +1976,7 @@ private fun formatDayTitle(
|
|||||||
abbreviated: Boolean = false,
|
abbreviated: Boolean = false,
|
||||||
): String =
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
date = date.toJavaLocalDate(),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -41,11 +42,9 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.toKotlinUtcOffset
|
import kotlinx.datetime.toKotlinUtcOffset
|
||||||
import kotlinx.datetime.toJavaZoneId
|
import kotlinx.datetime.toJavaZoneId
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.daysUntil
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -342,19 +341,9 @@ internal fun layoutAllDay(
|
|||||||
private infix fun IntRange.overlaps(other: IntRange): Boolean =
|
private infix fun IntRange.overlaps(other: IntRange): Boolean =
|
||||||
first <= other.last && other.first <= last
|
first <= other.last && other.first <= last
|
||||||
|
|
||||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
|
||||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
return minus(offset, DateTimeUnit.DAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Instant range covering the [count] days starting at [start]. */
|
/** Instant range covering the [count] days starting at [start]. */
|
||||||
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> {
|
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||||
val from = start.atStartOfDayIn(zone)
|
dayRange(start, start.plus(count - 1, DateTimeUnit.DAY), zone)
|
||||||
val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
|
||||||
return from..to
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
|
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|||||||
+2
-3
@@ -794,11 +794,11 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `eventColorPalette delegates to the data source for the given calendar`(
|
fun `eventColorPalette reads the given calendar's published palette`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
) = runTest {
|
) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
eventColorPaletteResult = { id ->
|
publishedEventColorsResult = { id ->
|
||||||
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -929,7 +929,6 @@ class CalendarRepositoryImplTest {
|
|||||||
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
|
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
publishedEventColorsResult = { published }
|
publishedEventColorsResult = { published }
|
||||||
eventColorPaletteResult = { emptyList() }
|
|
||||||
}
|
}
|
||||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
|
||||||
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||||
@@ -80,8 +79,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
|
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
|
||||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||||
eventDetailResult(eventId)
|
eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
|
||||||
eventColorPaletteResult(calendarId)
|
|
||||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||||
publishedEventColorsResult(calendarId)
|
publishedEventColorsResult(calendarId)
|
||||||
override fun exportableEvents(
|
override fun exportableEvents(
|
||||||
|
|||||||
+12
-35
@@ -1,10 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.filter
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class FilterGroupingTest {
|
class AccountGroupsTest {
|
||||||
|
|
||||||
private fun cal(
|
private fun cal(
|
||||||
id: Long,
|
id: Long,
|
||||||
@@ -28,35 +28,18 @@ class FilterGroupingTest {
|
|||||||
cal(3, "Shared", "team@dav"),
|
cal(3, "Shared", "team@dav"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
val groups = calendars.groupByAccount()
|
||||||
|
|
||||||
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
assertThat(groups.map { it.label }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||||
assertThat(groups[0].calendars.map { it.displayName })
|
assertThat(groups[0].calendars.map { it.displayName })
|
||||||
.containsExactly("Personal", "Work").inOrder()
|
.containsExactly("Personal", "Work").inOrder()
|
||||||
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
|
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `hidden ids mark calendars not visible`() {
|
|
||||||
val calendars = listOf(
|
|
||||||
cal(1, "Personal", "alice@dav"),
|
|
||||||
cal(2, "Work", "alice@dav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
|
|
||||||
val rows = groups.single().calendars.associateBy { it.id }
|
|
||||||
|
|
||||||
assertThat(rows.getValue(1L).visible).isTrue()
|
|
||||||
assertThat(rows.getValue(2L).visible).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `blank account name falls back to type`() {
|
fun `blank account name falls back to type`() {
|
||||||
val groups = groupCalendarsForFilter(
|
val groups = listOf(cal(1, "Birthdays", account = "", type = "LOCAL")).groupByAccount()
|
||||||
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
assertThat(groups.single().label).isEqualTo("LOCAL")
|
||||||
hidden = emptySet(),
|
|
||||||
)
|
|
||||||
assertThat(groups.single().account).isEqualTo("LOCAL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #77: same address, two apps — two accounts, and they must read as two. */
|
/** #77: same address, two apps — two accounts, and they must read as two. */
|
||||||
@@ -67,7 +50,7 @@ class FilterGroupingTest {
|
|||||||
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
|
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
val groups = calendars.groupByAccount()
|
||||||
|
|
||||||
assertThat(groups).hasSize(2)
|
assertThat(groups).hasSize(2)
|
||||||
assertThat(groups.map { it.accountType })
|
assertThat(groups.map { it.accountType })
|
||||||
@@ -78,10 +61,7 @@ class FilterGroupingTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `one name under one type is not ambiguous`() {
|
fun `one name under one type is not ambiguous`() {
|
||||||
val groups = groupCalendarsForFilter(
|
val groups = listOf(cal(1, "Personal", "me@example.com")).groupByAccount()
|
||||||
listOf(cal(1, "Personal", "me@example.com")),
|
|
||||||
hidden = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(groups.single().ambiguous).isFalse()
|
assertThat(groups.single().ambiguous).isFalse()
|
||||||
}
|
}
|
||||||
@@ -89,13 +69,10 @@ class FilterGroupingTest {
|
|||||||
/** Two different names from the same app are still two plain groups. */
|
/** Two different names from the same app are still two plain groups. */
|
||||||
@Test
|
@Test
|
||||||
fun `different names under one type are not ambiguous`() {
|
fun `different names under one type are not ambiguous`() {
|
||||||
val groups = groupCalendarsForFilter(
|
val groups = listOf(
|
||||||
listOf(
|
cal(1, "Personal", "alice@example.com"),
|
||||||
cal(1, "Personal", "alice@example.com"),
|
cal(2, "Shared", "bob@example.com"),
|
||||||
cal(2, "Shared", "bob@example.com"),
|
).groupByAccount()
|
||||||
),
|
|
||||||
hidden = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(groups).hasSize(2)
|
assertThat(groups).hasSize(2)
|
||||||
assertThat(groups.none { it.ambiguous }).isTrue()
|
assertThat(groups.none { it.ambiguous }).isTrue()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -120,8 +121,8 @@ class ContinuousMonthIndexTest {
|
|||||||
val weeks = weekWindowFor(window, ws)
|
val weeks = weekWindowFor(window, ws)
|
||||||
window.forEach { index ->
|
window.forEach { index ->
|
||||||
val ym = yearMonthForIndex(index)
|
val ym = yearMonthForIndex(index)
|
||||||
val first = weekIndexOf(firstOfMonth(ym), ws)
|
val first = weekIndexOf(ym.firstDay, ws)
|
||||||
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
|
val last = weekIndexOf(yearMonthForIndex(index + 1).firstDay.minus(1, DateTimeUnit.DAY), ws)
|
||||||
assertThat(weeks.contains(first)).isTrue()
|
assertThat(weeks.contains(first)).isTrue()
|
||||||
assertThat(weeks.contains(last)).isTrue()
|
assertThat(weeks.contains(last)).isTrue()
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ class ContinuousMonthIndexTest {
|
|||||||
val wed = LocalDate(2026, 6, 10)
|
val wed = LocalDate(2026, 6, 10)
|
||||||
DayOfWeek.entries.forEach { ws ->
|
DayOfWeek.entries.forEach { ws ->
|
||||||
val index = weekIndexOf(wed, ws)
|
val index = weekIndexOf(wed, ws)
|
||||||
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws))
|
||||||
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||||
assertThat(index).isLessThan(continuousWeekCount(ws))
|
assertThat(index).isLessThan(continuousWeekCount(ws))
|
||||||
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
||||||
@@ -161,8 +162,8 @@ class ContinuousMonthIndexTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `firstOfMonth is the 1st`() {
|
fun `firstDay is the 1st`() {
|
||||||
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
|
assertThat(jun26.firstDay).isEqualTo(LocalDate(2026, 6, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.allDayEvent
|
import de.jeanlucmakiola.calendula.allDayEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
@@ -51,12 +52,12 @@ class MonthLayoutTest {
|
|||||||
) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt())
|
) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `startOfGridWeek snaps back to the configured week start`() {
|
fun `startOfWeek snaps back to the configured week start`() {
|
||||||
val wed = LocalDate(2026, 6, 10)
|
val wed = LocalDate(2026, 6, 10)
|
||||||
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
assertThat(jun8.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
// A Sunday-anchored week containing the 10th starts on the 7th.
|
// A Sunday-anchored week containing the 10th starts on the 7th.
|
||||||
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
assertThat(wed.startOfWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.testEvent
|
import de.jeanlucmakiola.calendula.testEvent
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|||||||
Reference in New Issue
Block a user