Compare commits
5 Commits
ddc5d56cc3
...
fix/fossif
| Author | SHA1 | Date | |
|---|---|---|---|
| db4611d284 | |||
| c0361686df | |||
| 3e3ff4fd2f | |||
| 2ed18e9938 | |||
|
|
d03985eada |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -5,3 +5,4 @@
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
|
app/src/test/resources/ics/*.ics -text
|
||||||
|
|||||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A failed import no longer costs you the whole file.** One event the calendar
|
||||||
|
refuses used to abort the entire import with nothing on screen but "couldn't
|
||||||
|
read this file". Events are now added one at a time: anything rejected is
|
||||||
|
counted and shown, and the rest still arrive. A faulty repeat rule in the file
|
||||||
|
is repaired instead of being handed on ([#225]).
|
||||||
|
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
||||||
|
chose, not at midnight UTC — the same rule the app already applied to all-day
|
||||||
|
events you create yourself — and an export no longer leaves them out of the
|
||||||
|
file entirely ([#225]).
|
||||||
|
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
||||||
|
Fossify writes the ones it mirrors from your contacts as events that start and
|
||||||
|
end on the same day, which the calendar read as lasting no time at all: they
|
||||||
|
imported without complaint and then appeared nowhere. Any all-day event that
|
||||||
|
ends where it starts, or carries no end at all, is now a one-day event
|
||||||
|
([#225]).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||||
|
rather than being dropped in silence, deleted occurrences of a repeating event
|
||||||
|
stay deleted, a calendar named like the one the file came from is preselected,
|
||||||
|
and event colours are carried over — matched to the closest colour your
|
||||||
|
calendar's account offers, so a migrated calendar still reads at a glance
|
||||||
|
([#225]).
|
||||||
|
|
||||||
|
## [2.19.2] — 2026-08-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||||
|
week, day, agenda, search and both widgets — and no longer schedules a
|
||||||
|
reminder. Declining an invitation in Google Calendar left the event looking
|
||||||
|
like any other in Calendula, and it still notified you about a meeting you had
|
||||||
|
said no to. It stays visible rather than disappearing: the organiser still
|
||||||
|
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||||
|
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||||
|
the only way to reach an event from the month was to open its day first and
|
||||||
|
find it again there. Tapping anywhere else in the cell still opens the day
|
||||||
|
([#187]).
|
||||||
|
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||||
|
description to an event and opening it again showed the sheet as it was before
|
||||||
|
the save, because the detail was only re-read when a different occurrence was
|
||||||
|
opened ([#196]).
|
||||||
|
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||||
|
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||||
|
more than the launcher's buffer takes. The third update overran it, and Android
|
||||||
|
responded by dropping the whole widget host, which killed updates for *every*
|
||||||
|
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||||
|
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||||
|
instead of clipping ([#214]).
|
||||||
|
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||||
|
instead of asking Android to update all of them, which does nothing in a
|
||||||
|
process the tap has just woken from cold ([#18]).
|
||||||
|
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||||
|
leaving a sliver of the previous row on screen ([#191]).
|
||||||
|
- Day and week view no longer run their events flush against the right edge
|
||||||
|
([#192]).
|
||||||
|
|
||||||
## [2.19.1] — 2026-08-11
|
## [2.19.1] — 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1414,3 +1474,10 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
|
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
|||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -90,8 +91,10 @@ interface CalendarDataSource {
|
|||||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||||
* given, only those calendars are exported (still intersected with the
|
* given, only those calendars are exported (still intersected with the
|
||||||
* eligible set); `null` exports every eligible calendar.
|
* eligible set); `null` exports every eligible calendar.
|
||||||
|
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||||
|
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||||
*/
|
*/
|
||||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -101,10 +104,18 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
* minting one when absent); returns the new `Events._ID`.
|
||||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
*
|
||||||
|
* [colorPalette] is the target account's published event colours, looked up
|
||||||
|
* once per import; [allDayReminderTimeMinutes] is the user's preferred
|
||||||
|
* all-day firing time, applied exactly as a hand-created event's is.
|
||||||
*/
|
*/
|
||||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
fun insertImportedEvent(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
@@ -743,7 +754,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
override fun exportableEvents(
|
||||||
|
calendarIds: Set<Long>?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<IcsEvent> {
|
||||||
// Only the local calendars the app owns and can write — synced calendars
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
// already have a backup (their server). Exclude the managed special-dates
|
// already have a backup (their server). Exclude the managed special-dates
|
||||||
// mirror calendars: their events are derived from contacts, not authored
|
// mirror calendars: their events are derived from contacts, not authored
|
||||||
@@ -778,6 +792,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
reader.toIcsEvent(
|
reader.toIcsEvent(
|
||||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
calendarName = names[calendarId],
|
calendarName = names[calendarId],
|
||||||
|
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -786,55 +801,53 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
arrayOf(CalendarContract.Events.UID_2445),
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
// DELETED rows linger until a sync adapter purges them; counting those
|
||||||
|
// as present would make a re-import skip everything the user has since
|
||||||
|
// deleted, reporting "all duplicates" and importing nothing.
|
||||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
arrayOf(calendarId.toString()),
|
arrayOf(calendarId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
} ?: emptySet()
|
} ?: emptySet()
|
||||||
|
|
||||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
override fun insertImportedEvent(
|
||||||
val startMillis = event.start.toEpochMillis()
|
event: ParsedIcsEvent,
|
||||||
val endMillis = event.end.toEpochMillis()
|
calendarId: Long,
|
||||||
val values = ContentValues().apply {
|
allDayReminderTimeMinutes: Int,
|
||||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long {
|
||||||
|
val values = buildImportedEventValues(
|
||||||
|
event = event,
|
||||||
|
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.
|
||||||
put(
|
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
CalendarContract.Events.UID_2445,
|
palette = colorPalette,
|
||||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
)
|
||||||
)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, startMillis)
|
|
||||||
if (event.recurrenceRule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, endMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
importDuration(startMillis, endMillis, event.isAllDay),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
|
||||||
// timed rows keep the event's own zone.
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
|
||||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
}
|
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
|
||||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
event.semanticReminderMinutes().forEach { minutes ->
|
||||||
|
// An all-day reminder is stored the same way a hand-created one is,
|
||||||
|
// so it fires at the time the user picked rather than at UTC midnight.
|
||||||
|
val providerMinutes = if (event.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = startDate,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
}
|
}
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
@@ -844,18 +857,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
|
||||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
|
||||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
|
||||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
|
||||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
|
||||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
|
||||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -210,26 +212,41 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||||
|
|
||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = withContext(io) {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||||
|
// thousand-event restore to two extra queries.
|
||||||
|
val palette = dataSource.eventColorPalette(targetCalendarId)
|
||||||
|
val allDayMinutes = allDayReminderTimeMinutes()
|
||||||
var imported = 0
|
var imported = 0
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
|
var failed = 0
|
||||||
for (event in events) {
|
for (event in events) {
|
||||||
// A known UID means the event is already in this calendar — skip,
|
// A known UID means the event is already in this calendar — skip,
|
||||||
// keeping a restore idempotent (no overwrite this pass).
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
if (event.uid != null && event.uid in existing) {
|
if (event.uid != null && event.uid in existing) {
|
||||||
skipped++
|
skipped++
|
||||||
} else {
|
continue
|
||||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
}
|
||||||
|
try {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||||
imported++
|
imported++
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// One event the provider won't take must not cost the user the
|
||||||
|
// rest of the file — foreign exports do carry rows it rejects
|
||||||
|
// outright (a malformed RRULE throws straight out of insert).
|
||||||
|
failed++
|
||||||
|
Log.w(TAG, "Skipped an unimportable event", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
@@ -288,6 +305,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarRepository"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -499,3 +503,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
|||||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||||
|
*
|
||||||
|
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||||
|
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||||
|
* parser hands them over.
|
||||||
|
*
|
||||||
|
* [palette] is the target account's published event colours. A calendar that
|
||||||
|
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||||
|
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||||
|
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||||
|
*/
|
||||||
|
internal fun buildImportedEventValues(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
uid: String,
|
||||||
|
palette: List<EventColorOption>,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
val startMillis = event.start.toEpochMilliseconds()
|
||||||
|
val endMillis = event.end.toEpochMilliseconds()
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
event.exDates.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
event.color?.let { color ->
|
||||||
|
val key = palette.nearestTo(color)?.key
|
||||||
|
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
||||||
|
* series, seconds otherwise. An all-day series is never shorter than a day —
|
||||||
|
* the provider expands a zero-length one into no instances at all, so the
|
||||||
|
* series would vanish (which is what a literal read of the Fossify family's
|
||||||
|
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
||||||
|
* docs/ARCHITECTURE.md).
|
||||||
|
*/
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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 de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
import java.time.Instant
|
||||||
|
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]
|
||||||
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* [calendarName] the display name of its calendar (emitted as
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
* MapColumnReader.
|
* MapColumnReader.
|
||||||
|
*
|
||||||
|
* An all-day row's raw offset has the firing time of day encoded into it and is
|
||||||
|
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
||||||
|
* would be dropped as a trigger that fires after the event, losing the reminder.
|
||||||
|
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
||||||
|
* time the file should carry, which an import re-encodes for its own device.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toIcsEvent(
|
internal fun ColumnReader.toIcsEvent(
|
||||||
reminderMinutes: List<Int>,
|
reminderMinutes: List<Int>,
|
||||||
calendarName: String?,
|
calendarName: String?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
): IcsEvent {
|
): IcsEvent {
|
||||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
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
|
||||||
|
|
||||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
@@ -40,13 +52,20 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
?: "UTC",
|
?: "UTC",
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = rrule,
|
||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
reminderMinutes = reminderMinutes,
|
reminderMinutes = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reminderMinutes.map {
|
||||||
|
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reminderMinutes
|
||||||
|
},
|
||||||
status = status,
|
status = status,
|
||||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
@@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(InstanceProjection.IDX_LOCATION),
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
|
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ internal object InstanceProjection {
|
|||||||
CalendarContract.Instances.EVENT_COLOR,
|
CalendarContract.Instances.EVENT_COLOR,
|
||||||
CalendarContract.Instances.CALENDAR_COLOR,
|
CalendarContract.Instances.CALENDAR_COLOR,
|
||||||
CalendarContract.Instances.EVENT_LOCATION,
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
|
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_INSTANCE_ID = 0
|
const val IDX_INSTANCE_ID = 0
|
||||||
@@ -64,6 +65,7 @@ internal object InstanceProjection {
|
|||||||
const val IDX_EVENT_COLOR = 7
|
const val IDX_EVENT_COLOR = 7
|
||||||
const val IDX_CALENDAR_COLOR = 8
|
const val IDX_CALENDAR_COLOR = 8
|
||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object EventDetailProjection {
|
internal object EventDetailProjection {
|
||||||
@@ -182,6 +184,7 @@ internal object SearchProjection {
|
|||||||
CalendarContract.Events.RDATE,
|
CalendarContract.Events.RDATE,
|
||||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||||
CalendarContract.Events.DESCRIPTION,
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -197,6 +200,7 @@ internal object SearchProjection {
|
|||||||
const val IDX_RRULE = 10
|
const val IDX_RRULE = 10
|
||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
const val IDX_DESCRIPTION = 12
|
const val IDX_DESCRIPTION = 12
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
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
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
@@ -42,5 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
|
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,16 @@ class ProviderReminderInstanceSource @Inject constructor(
|
|||||||
// `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.
|
||||||
|
// An invitation the user declined is answered — it stays on the calendar
|
||||||
|
// struck through, but it plans nothing (#180). NULL again means "no
|
||||||
|
// answer recorded", which is not a "no".
|
||||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
"${CalendarContract.Instances.STATUS} != " +
|
||||||
|
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
|
||||||
|
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
|
||||||
return context.contentResolver.query(
|
return context.contentResolver.query(
|
||||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
|
|||||||
@@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36
|
|||||||
* apart that two swatches never read as the same colour in the grid.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val MIN_DISTANCE = 0.025f
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette entry closest to [argb], or null when the account publishes no
|
||||||
|
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||||
|
*
|
||||||
|
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||||
|
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||||
|
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||||
|
* closest rather than the one that is closest in RGB coordinates.
|
||||||
|
*/
|
||||||
|
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||||
|
val target = oklchOf(argb)
|
||||||
|
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ data class EventInstance(
|
|||||||
* Instances query already yields one row per occurrence.
|
* Instances query already yields one row per occurrence.
|
||||||
*/
|
*/
|
||||||
val isRecurring: Boolean = false,
|
val isRecurring: Boolean = false,
|
||||||
|
/**
|
||||||
|
* This device user answered "no" to the invitation
|
||||||
|
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||||
|
* still an appointment someone expects an answer about — but every surface
|
||||||
|
* strikes it through, and it plans no reminders (#180).
|
||||||
|
*/
|
||||||
|
val isDeclined: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||||
|
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||||
|
*
|
||||||
|
* Three encodings are in the wild and all appear in the files Calendula is
|
||||||
|
* asked to import:
|
||||||
|
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||||
|
* (`COLOR:tomato`);
|
||||||
|
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||||
|
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||||
|
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||||
|
*
|
||||||
|
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||||
|
* lookup misses — resolves to null.
|
||||||
|
*/
|
||||||
|
fun parseIcsColorValue(raw: String?): Int? {
|
||||||
|
val value = raw?.trim().orEmpty()
|
||||||
|
if (value.isEmpty()) return null
|
||||||
|
|
||||||
|
if (value.startsWith("#")) {
|
||||||
|
val hex = value.substring(1)
|
||||||
|
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||||
|
return when (hex.length) {
|
||||||
|
6 -> parsed or OPAQUE_ALPHA
|
||||||
|
8 -> parsed
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||||
|
return cssColors[value.lowercase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||||
|
private val cssColors: Map<String, Int> by lazy {
|
||||||
|
CSS3_TABLE.split(' ').associate { entry ->
|
||||||
|
val name = entry.substringBefore('=')
|
||||||
|
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CSS3_TABLE =
|
||||||
|
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||||
|
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||||
|
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||||
|
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||||
|
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||||
|
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||||
|
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||||
|
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||||
|
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||||
|
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||||
|
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||||
|
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||||
|
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||||
|
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||||
|
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||||
|
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||||
|
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||||
|
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||||
|
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||||
|
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||||
|
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||||
|
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||||
|
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||||
|
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||||
|
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||||
|
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||||
|
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||||
|
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||||
|
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||||
|
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||||
|
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||||
|
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||||
|
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||||
|
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||||
|
"yellow=ffff00 yellowgreen=9acd32"
|
||||||
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
|
|||||||
import kotlinx.datetime.LocalTime
|
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 kotlin.math.ceil
|
||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
* how the writer encoded them.
|
* how the writer encoded them.
|
||||||
|
*
|
||||||
|
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||||
|
* them — minutes *before* start, so a negative entry means "after start".
|
||||||
|
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||||
*/
|
*/
|
||||||
data class ParsedIcsEvent(
|
data class ParsedIcsEvent(
|
||||||
val uid: String?,
|
val uid: String?,
|
||||||
@@ -30,8 +41,42 @@ data class ParsedIcsEvent(
|
|||||||
val status: EventStatus = EventStatus.Confirmed,
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
val availability: Availability = Availability.Busy,
|
val availability: Availability = Availability.Busy,
|
||||||
val calendarName: String? = null,
|
val calendarName: String? = null,
|
||||||
|
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||||
|
val color: Int? = null,
|
||||||
|
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||||
|
val exDates: List<String> = emptyList(),
|
||||||
|
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||||
|
val isTask: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||||
|
* the event, never negative.
|
||||||
|
*
|
||||||
|
* For a timed event the raw lead time already is that. All-day reminders are
|
||||||
|
* whole days before the event, fired at the user's configured time of day (see
|
||||||
|
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
||||||
|
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
||||||
|
*
|
||||||
|
* Rounding to the nearest day instead would lose a day for every producer whose
|
||||||
|
* all-day notifications fire after noon: a file's raw offset is
|
||||||
|
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
||||||
|
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
||||||
|
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
||||||
|
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
||||||
|
* ceils to zero days before.
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||||
|
.map { raw ->
|
||||||
|
if (isAllDay) {
|
||||||
|
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||||
|
} else {
|
||||||
|
raw.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
|
||||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
enum class IcsParseWarning {
|
enum class IcsParseWarning {
|
||||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
@@ -45,6 +90,12 @@ enum class IcsParseWarning {
|
|||||||
|
|
||||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
UnknownTimezone,
|
UnknownTimezone,
|
||||||
|
|
||||||
|
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||||
|
TasksImportedAsEvents,
|
||||||
|
|
||||||
|
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||||
|
RecurrenceRuleRepaired,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class IcsParseResult(
|
data class IcsParseResult(
|
||||||
@@ -52,8 +103,16 @@ data class IcsParseResult(
|
|||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
/**
|
||||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||||
|
* provider rejected — the import continues past them, so a single unusable
|
||||||
|
* event can't cost the user the rest of the file.
|
||||||
|
*/
|
||||||
|
data class IcsImportSummary(
|
||||||
|
val imported: Int,
|
||||||
|
val skippedDuplicate: Int,
|
||||||
|
val failed: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
@@ -62,6 +121,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
|||||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*
|
||||||
|
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||||
|
* tasks, and dropping them would silently lose half of some exports.
|
||||||
*/
|
*/
|
||||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
@@ -69,37 +131,49 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
val lines = unfoldLines(text)
|
val lines = unfoldLines(text)
|
||||||
val events = mutableListOf<ParsedIcsEvent>()
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
val warnings = mutableSetOf<IcsParseWarning>()
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
var calendarName: String? = null
|
// Scanned up front rather than in document order: an X-WR-CALNAME after
|
||||||
|
// the first VEVENT still names the calendar every event came from.
|
||||||
|
val calendarName = lines.asSequence()
|
||||||
|
.mapNotNull(::parseContentLine)
|
||||||
|
.firstOrNull { it.name == "X-WR-CALNAME" }
|
||||||
|
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
val line = parseContentLine(lines[i])
|
val line = parseContentLine(lines[i])
|
||||||
if (line == null) { i++; continue }
|
if (line == null) { i++; continue }
|
||||||
|
val component = when {
|
||||||
|
line.isBegin("VEVENT") -> "VEVENT"
|
||||||
|
line.isBegin("VTODO") -> "VTODO"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
when {
|
when {
|
||||||
line.isBegin("VEVENT") -> {
|
component != null -> {
|
||||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
val end = indexOfEnd(lines, i + 1, component)
|
||||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
parseComponent(
|
||||||
?.let(events::add)
|
body = lines.subList(i + 1, end),
|
||||||
|
fileCalendarName = calendarName,
|
||||||
|
warnings = warnings,
|
||||||
|
isTask = component == "VTODO",
|
||||||
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
}
|
}
|
||||||
line.name == "X-WR-CALNAME" -> {
|
|
||||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
else -> i++
|
else -> i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||||
return IcsParseResult(events, warnings)
|
return IcsParseResult(events, warnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseVevent(
|
private fun parseComponent(
|
||||||
body: List<String>,
|
body: List<String>,
|
||||||
fileCalendarName: String?,
|
fileCalendarName: String?,
|
||||||
warnings: MutableSet<IcsParseWarning>,
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
isTask: Boolean,
|
||||||
): ParsedIcsEvent? {
|
): ParsedIcsEvent? {
|
||||||
var uid: String? = null
|
var uid: String? = null
|
||||||
var summary = ""
|
var summary = ""
|
||||||
@@ -112,7 +186,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
var status = EventStatus.Confirmed
|
var status = EventStatus.Confirmed
|
||||||
var availability = Availability.Busy
|
var availability = Availability.Busy
|
||||||
var calendarName = fileCalendarName
|
var calendarName = fileCalendarName
|
||||||
|
var eventColor: Int? = null
|
||||||
|
var calendarColor: Int? = null
|
||||||
val reminders = mutableListOf<Int>()
|
val reminders = mutableListOf<Int>()
|
||||||
|
val exDateLines = mutableListOf<IcsContentLine>()
|
||||||
var skipAsOverride = false
|
var skipAsOverride = false
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
@@ -129,9 +206,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
"SUMMARY" -> summary = unescapeText(line.value)
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
// A VTODO has no DTSTART of its own in most producers' output.
|
||||||
|
"DUE" -> if (isTask && dtStart == null) dtStart = parseIcsDateTime(line, warnings)
|
||||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
"DURATION" -> duration = line.value.trim()
|
"DURATION" -> duration = line.value.trim()
|
||||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"EXDATE" -> exDateLines.add(line)
|
||||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
"STATUS" -> status = mapIcsStatus(line.value)
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
@@ -142,6 +222,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
"X-CALENDULA-CALENDAR" ->
|
"X-CALENDULA-CALENDAR" ->
|
||||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
// Fossify names the source calendar per event; it never writes
|
||||||
|
// an X-WR-CALNAME, so this is the only calendar label its files carry.
|
||||||
|
"CATEGORIES" -> calendarName = unescapeText(line.value).trim()
|
||||||
|
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||||
|
?: calendarName
|
||||||
|
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||||
|
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||||
|
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||||
|
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||||
|
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||||
|
// folds several source calendars into one keeps them apart visually.
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||||
|
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
@@ -154,33 +247,111 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val end = dtEnd
|
val cleanRrule = sanitizeRrule(rrule)
|
||||||
?: duration?.let {
|
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
|
||||||
start.copy(
|
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
instant = Instant.fromEpochMilliseconds(
|
}
|
||||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
val end = resolveEnd(start, dtEnd, duration)
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
?: start
|
|
||||||
return ParsedIcsEvent(
|
return ParsedIcsEvent(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
start = start.instant,
|
start = start.instant,
|
||||||
end = end.instant,
|
end = end,
|
||||||
isAllDay = start.isAllDay,
|
isAllDay = start.isAllDay,
|
||||||
zoneId = start.zoneId,
|
zoneId = start.zoneId,
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = cleanRrule,
|
||||||
location = location,
|
location = location,
|
||||||
description = description,
|
description = description,
|
||||||
reminderMinutes = reminders.distinct(),
|
reminderMinutes = reminders.distinct(),
|
||||||
status = status,
|
status = status,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
|
color = eventColor ?: calendarColor,
|
||||||
|
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start),
|
||||||
|
isTask = isTask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
/**
|
||||||
|
* The event's end instant.
|
||||||
|
*
|
||||||
|
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
||||||
|
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
||||||
|
* since the provider expands a zero-length all-day series into no instances
|
||||||
|
* at all — an all-day event may never end at or before it starts.
|
||||||
|
*/
|
||||||
|
private fun resolveEnd(
|
||||||
|
start: IcsDateTime,
|
||||||
|
dtEnd: IcsDateTime?,
|
||||||
|
duration: String?,
|
||||||
|
): Instant {
|
||||||
|
val explicit = dtEnd?.instant ?: duration?.let {
|
||||||
|
Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!start.isAllDay) return explicit ?: start.instant
|
||||||
|
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||||
|
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||||
|
*
|
||||||
|
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||||
|
* out that way even for timed series, where the property is then ambiguous.
|
||||||
|
* Such a value is resolved against the series' own time of day, which is
|
||||||
|
* what it meant — its repetitions never move within the day.
|
||||||
|
*/
|
||||||
|
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||||
|
if (lines.isEmpty()) return emptyList()
|
||||||
|
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||||
|
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||||
|
return lines
|
||||||
|
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||||
|
.mapNotNull { (line, token) ->
|
||||||
|
if (token.isEmpty()) return@mapNotNull null
|
||||||
|
if (token.contains('T')) {
|
||||||
|
val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null
|
||||||
|
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant)
|
||||||
|
} else {
|
||||||
|
val date = parseBasicDate(token) ?: return@mapNotNull null
|
||||||
|
if (start.isAllDay) {
|
||||||
|
utcDayCode(date.atStartOfDayIn(TimeZone.UTC))
|
||||||
|
} else {
|
||||||
|
utcStamp(LocalDateTime(date, startTime).toInstant(startZone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
||||||
|
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
||||||
|
* and reading it in the device's zone instead would put the exclusion on an
|
||||||
|
* instant no occurrence has.
|
||||||
|
*/
|
||||||
|
private fun parseExDateTime(
|
||||||
|
token: String,
|
||||||
|
line: IcsContentLine,
|
||||||
|
startZone: TimeZone,
|
||||||
|
): Instant? {
|
||||||
|
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||||
|
val zone = when {
|
||||||
|
token.endsWith("Z") -> TimeZone.UTC
|
||||||
|
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||||
|
?: startZone
|
||||||
|
}
|
||||||
|
return ldt.toInstant(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||||
|
* trigger that fires after the start (which is how the Fossify family
|
||||||
|
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||||
|
* isn't a usable relative offset.
|
||||||
|
*/
|
||||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
val trigger = body.asSequence()
|
val trigger = body.asSequence()
|
||||||
.mapNotNull { parseContentLine(it) }
|
.mapNotNull { parseContentLine(it) }
|
||||||
@@ -189,8 +360,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
// Negative = before start (the normal case) → positive lead minutes.
|
return (-millis / 60_000L).toInt()
|
||||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
@@ -218,6 +388,17 @@ 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 utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||||
|
"%04d%02d%02d".format(year, month.number, 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)
|
||||||
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
private val VALID_FREQ = setOf(
|
||||||
|
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */
|
||||||
|
private val LIST_PARTS = setOf(
|
||||||
|
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
|
||||||
|
"BYWEEKNO", "BYMONTH", "BYSETPOS",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it
|
||||||
|
* can't be salvaged.
|
||||||
|
*
|
||||||
|
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||||
|
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||||
|
* from a later read. One bad rule in an imported file would therefore abort the
|
||||||
|
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||||
|
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||||
|
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||||
|
* the state every weekly event it imported from elsewhere is left in.
|
||||||
|
*
|
||||||
|
* Empty and malformed parts are dropped rather than failing the rule; only a
|
||||||
|
* missing or unrecognised `FREQ` — without which there is no rule at all —
|
||||||
|
* returns null.
|
||||||
|
*/
|
||||||
|
fun sanitizeRrule(raw: String?): String? {
|
||||||
|
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||||
|
if (rule.isEmpty()) return null
|
||||||
|
|
||||||
|
var freqSeen = false
|
||||||
|
val parts = rule.split(';').mapNotNull { part ->
|
||||||
|
val key = part.substringBefore('=', "").trim().uppercase()
|
||||||
|
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||||
|
if (key.isEmpty() || value.isEmpty()) return@mapNotNull null
|
||||||
|
when {
|
||||||
|
key == "FREQ" -> {
|
||||||
|
if (value.uppercase() !in VALID_FREQ) return null
|
||||||
|
freqSeen = true
|
||||||
|
"FREQ=${value.uppercase()}"
|
||||||
|
}
|
||||||
|
// A non-positive or non-numeric INTERVAL is rejected by the provider.
|
||||||
|
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||||
|
key in LIST_PARTS -> value.split(',')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { "$key=${it.joinToString(",")}" }
|
||||||
|
else -> "$key=$value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (freqSeen) parts.joinToString(";") else null
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
* calendar, exactly like a fresh create — the user confirms the target and
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*
|
||||||
|
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||||
|
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||||
|
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||||
|
* where the target is known up front, does keep it.
|
||||||
*/
|
*/
|
||||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
val (start, end) = if (isAllDay) {
|
val (start, end) = if (isAllDay) {
|
||||||
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
end = end,
|
end = end,
|
||||||
location = location.orEmpty(),
|
location = location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminderMinutes.distinct().sorted(),
|
reminders = semanticReminderMinutes(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
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.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
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
|
||||||
@@ -129,8 +131,8 @@ internal fun AgendaEventRow(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = title,
|
title = declinedTitle(title, event.isDeclined),
|
||||||
summary = agendaTimeSummary(event, day, zone),
|
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
leading = {
|
leading = {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ val GUTTER_WIDTH = 48.dp
|
|||||||
*/
|
*/
|
||||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End inset for everything that lines up with the day columns — the week's day
|
||||||
|
* header, the all-day strip and the timeline itself. Without it the last column
|
||||||
|
* runs flush into the screen edge while the gutter gives the other side room
|
||||||
|
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
|
||||||
|
*/
|
||||||
|
val TIMELINE_CONTENT_END_INSET = 8.dp
|
||||||
|
|
||||||
private val BADGE_HEIGHT = 20.dp
|
private val BADGE_HEIGHT = 20.dp
|
||||||
|
|
||||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
|||||||
|
|
||||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||||
const val EventDimAlpha = 0.4f
|
const val EventDimAlpha = 0.4f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a declined invitation's title is struck through (#180) — the mark every
|
||||||
|
* surface uses to say "you answered no", chosen over hiding the event because it
|
||||||
|
* is still something the organiser expects you at. Null for everything else, so
|
||||||
|
* it drops straight into a `Text`'s `textDecoration`.
|
||||||
|
*/
|
||||||
|
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||||
|
if (isDeclined) TextDecoration.LineThrough else null
|
||||||
|
|
||||||
|
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
||||||
|
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
|
||||||
|
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||||
|
} else {
|
||||||
|
AnnotatedString(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
||||||
|
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||||
|
if (!isDeclined) {
|
||||||
|
title
|
||||||
|
} else {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(title)
|
||||||
|
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -118,6 +119,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
@@ -471,7 +473,11 @@ private fun AllDayStrip(
|
|||||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||||
// inside it so the content area is lanes * row height.
|
// inside it so the content area is lanes * row height.
|
||||||
.height(height)
|
.height(height)
|
||||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
.padding(
|
||||||
|
top = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day column.
|
// Keep the gutter-width offset so the bars line up with the day column.
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
@@ -525,6 +531,7 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +577,7 @@ private fun Timeline(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
@@ -772,6 +780,7 @@ private fun EventBlock(
|
|||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
@@ -792,7 +801,12 @@ private fun DayLoading() {
|
|||||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
// doesn't resize the moment the real day arrives.
|
// doesn't resize the moment the real day arrives.
|
||||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _target = MutableStateFlow<Target?>(null)
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
// Bumped by retry() to re-run the load for the same target.
|
// Bumped by retry() and by re-opening the target already shown, to re-run
|
||||||
|
// the load without changing the target.
|
||||||
private val _reload = MutableStateFlow(0)
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
// Last target whose content is already on screen; a re-read of it skips the
|
||||||
|
// Loading skeleton so the sheet doesn't blank out between two identical reads.
|
||||||
|
private var loadedTarget: Target? = null
|
||||||
|
|
||||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
combine(_target, _reload) { target, _ -> target }
|
combine(_target, _reload) { target, _ -> target }
|
||||||
.flatMapLatest { target ->
|
.flatMapLatest { target ->
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
|
loadedTarget = null
|
||||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||||
} else {
|
} else {
|
||||||
flow {
|
flow {
|
||||||
emit(EventDetailUiState.Loading)
|
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
||||||
emit(loadDetail(target))
|
val loaded = loadDetail(target)
|
||||||
|
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
||||||
|
emit(loaded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,9 +86,15 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
* the occurrence's own times (from `CalendarContract.Instances`); they
|
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||||
* override the series DTSTART/DTEND so recurring events show the correct
|
* override the series DTSTART/DTEND so recurring events show the correct
|
||||||
* date instead of the first occurrence.
|
* date instead of the first occurrence.
|
||||||
|
*
|
||||||
|
* Re-opening the *same* occurrence always re-reads it. The view model
|
||||||
|
* outlives the sheet, and a `StateFlow` conflates an identical value away,
|
||||||
|
* so assigning the target alone would leave an edit that changed no time —
|
||||||
|
* adding a description, say (#196) — showing the state from before the save.
|
||||||
*/
|
*/
|
||||||
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
_target.value = Target(eventId, beginMillis, endMillis)
|
val target = Target(eventId, beginMillis, endMillis)
|
||||||
|
if (_target.value == target) _reload.value += 1 else _target.value = target
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-run the current load after a failure. */
|
/** Re-run the current load after a failure. */
|
||||||
|
|||||||
@@ -93,15 +93,11 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
// read it without the user scrolling to a bottom button. Defaults to the
|
// read it without the user scrolling to a bottom button (see
|
||||||
// first *local* calendar — the first row the picker shows ("Your calendars"
|
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
|
||||||
// group leads) — so the pre-selection lines up with the top of the list;
|
// first arrives (keyed on it), then holds the pick.
|
||||||
// falls back to the first calendar if there are no local ones. Re-defaults
|
|
||||||
// when the "many" list first arrives (keyed on it), then holds the pick.
|
|
||||||
val many = state as? ImportUiState.Many
|
val many = state as? ImportUiState.Many
|
||||||
val defaultTarget = many?.calendars?.let { cals ->
|
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
|
||||||
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
|
||||||
}
|
|
||||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -252,6 +248,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
if (state.summary.failed > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_failed_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (state.summary.skippedDuplicate > 0) {
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -290,6 +295,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (state.summary.failed > 0) {
|
||||||
|
ImportStatCard(
|
||||||
|
count = state.summary.failed,
|
||||||
|
label = stringResource(R.string.import_done_failed_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
|
R.plurals.import_done_failed,
|
||||||
|
state.summary.failed,
|
||||||
|
state.summary.failed,
|
||||||
|
),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
onContainer = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Button(
|
Button(
|
||||||
@@ -301,7 +319,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun RowScope.ImportStatCard(
|
private fun RowScope.ImportStatCard(
|
||||||
count: Int,
|
count: Int,
|
||||||
@@ -343,6 +361,9 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
|
||||||
|
IcsParseWarning.RecurrenceRuleRepaired ->
|
||||||
|
stringResource(R.string.import_warning_recurrence_repaired)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ sealed interface ImportUiState {
|
|||||||
val events: List<ParsedIcsEvent>,
|
val events: List<ParsedIcsEvent>,
|
||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
|
/**
|
||||||
|
* The calendar the file says its events came from, when it names exactly
|
||||||
|
* one — used to preselect a matching target. Null when the file names
|
||||||
|
* none or its events disagree.
|
||||||
|
*/
|
||||||
|
val fileCalendarName: String? = null,
|
||||||
) : ImportUiState
|
) : ImportUiState
|
||||||
|
|
||||||
data class Done(val summary: IcsImportSummary) : ImportUiState
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
@@ -92,6 +98,10 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.isEventTarget },
|
.filter { it.isEventTarget },
|
||||||
|
fileCalendarName = parsed.events
|
||||||
|
.mapNotNull { it.calendarName }
|
||||||
|
.distinct()
|
||||||
|
.singleOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,3 +123,22 @@ class ImportViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target calendar to preselect for a bulk import.
|
||||||
|
*
|
||||||
|
* A calendar named like the one the file came from wins — restoring "Birthdays"
|
||||||
|
* onto the Birthdays calendar is what the user means. Otherwise the first
|
||||||
|
* *local* calendar, which is the first row the picker shows ("Your calendars"
|
||||||
|
* leads), so the pre-selection lines up with the top of the list; failing that
|
||||||
|
* the first calendar of any kind.
|
||||||
|
*/
|
||||||
|
internal fun defaultImportTarget(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
): Long? {
|
||||||
|
val named = fileCalendarName?.let { name ->
|
||||||
|
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
|||||||
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.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
@@ -765,6 +766,9 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
|||||||
*/
|
*/
|
||||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||||
|
|
||||||
|
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||||
|
private val DENSE_HEADER_GAP = 4.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MonthGrid(
|
internal fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
@@ -960,10 +964,15 @@ internal fun DenseMonthGrid(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp),
|
// The gap under the weekday header is a real margin, not content
|
||||||
|
// padding: a lazy list scrolls its rows *through* the before-content
|
||||||
|
// padding, so scrolling to a week landed its row that far down with
|
||||||
|
// the tail of the previous one showing above it (#191).
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// 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 = 4.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
items(count = weekCount, key = { it }) { index ->
|
items(count = weekCount, key = { it }) { index ->
|
||||||
val week = state.weeksByIndex[index]
|
val week = state.weeksByIndex[index]
|
||||||
@@ -1813,7 +1822,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
modifier = Modifier.padding(bottom = 8.dp),
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(DENSE_HEADER_GAP))
|
||||||
}
|
}
|
||||||
// More rows than a viewport holds; the clip takes the overflow.
|
// More rows than a viewport holds; the clip takes the overflow.
|
||||||
repeat(6) {
|
repeat(6) {
|
||||||
@@ -2168,7 +2177,7 @@ private fun MonthWeekRow(
|
|||||||
// leaves both the click and a pickup in flight untouched. Padded and
|
// leaves both the click and a pickup in flight untouched. Padded and
|
||||||
// clipped to the background pill so the ripple matches it. A blanked
|
// clipped to the background pill so the ripple matches it. A blanked
|
||||||
// cell isn't part of this month, so it takes no taps either.
|
// cell isn't part of this month, so it takes no taps either.
|
||||||
val downY = remember(week.days.size) { FloatArray(week.days.size) }
|
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEachIndexed { col, d ->
|
week.days.forEachIndexed { col, d ->
|
||||||
if (blankOutside && !inMonth(d)) {
|
if (blankOutside && !inMonth(d)) {
|
||||||
@@ -2189,9 +2198,14 @@ private fun MonthWeekRow(
|
|||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable {
|
.clickable {
|
||||||
|
// Cleared on read: a click with no fresh down
|
||||||
|
// (TalkBack, D-pad) would otherwise resolve the
|
||||||
|
// previous tap's position and reopen its chip.
|
||||||
|
val cellY = downY[col]
|
||||||
|
downY[col] = NO_DOWN_Y
|
||||||
val chip = week.chipAtCellY(
|
val chip = week.chipAtCellY(
|
||||||
col = col,
|
col = col,
|
||||||
cellY = downY[col],
|
cellY = cellY,
|
||||||
bandTopInCell = bandTopInCell(
|
bandTopInCell = bandTopInCell(
|
||||||
cellCoordinates,
|
cellCoordinates,
|
||||||
bandCoordinates,
|
bandCoordinates,
|
||||||
@@ -2222,6 +2236,11 @@ private fun bandTopInCell(
|
|||||||
return bandTop - cellTop
|
return bandTop - cellTop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
|
||||||
|
*/
|
||||||
|
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
|
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
|
||||||
* the row's day-column box. Null for a tap above the band (the day number), on an
|
* the row's day-column box. Null for a tap above the band (the day number), on an
|
||||||
@@ -2440,6 +2459,7 @@ private fun MonthBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import de.jeanlucmakiola.calendula.domain.SearchMonth
|
|||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -519,7 +520,7 @@ private fun SearchResultRow(
|
|||||||
// Faded like a past event anywhere else in the app — search reaches back
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
// through the whole history.
|
// through the whole history.
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = marked(event.title, hit.titleSpans, highlight),
|
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||||
summary = searchSummary(hit, highlight),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ 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.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
@@ -129,6 +130,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -506,7 +508,7 @@ private fun WeekDayHeader(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||||
) {
|
) {
|
||||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
// badge lines up vertically with the date numbers. The start inset centres
|
// badge lines up vertically with the date numbers. The start inset centres
|
||||||
@@ -602,7 +604,11 @@ private fun AllDayStrip(
|
|||||||
// Height is hoisted + animated so it slides and resizes smoothly;
|
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||||
// padding sits inside it so the content area is lanes * row height.
|
// padding sits inside it so the content area is lanes * row height.
|
||||||
.height(height)
|
.height(height)
|
||||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
.padding(
|
||||||
|
top = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
@@ -662,6 +668,7 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,6 +715,7 @@ private fun Timeline(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
@@ -940,6 +948,7 @@ private fun EventBlock(
|
|||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
@@ -977,7 +986,12 @@ private fun WeekLoading() {
|
|||||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
// columns don't resize the moment the real week arrives.
|
// columns don't resize the moment the real week arrives.
|
||||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
repeat(7) {
|
repeat(7) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.material3.ColorProviders
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import androidx.glance.text.TextDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
|
||||||
@@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
GlanceTheme(colors = colors, content = content)
|
GlanceTheme(colors = colors, content = content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
||||||
|
* reads the same on the home screen as it does inside the app (#180).
|
||||||
|
*/
|
||||||
|
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||||
|
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -73,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
|
||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||||
* The range is read reactively in the composition ([currentState]) so a settings
|
* The range is read reactively in the composition ([currentState]) so a settings
|
||||||
@@ -112,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -135,9 +136,11 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-reads the calendar and redraws the widget (header refresh button). Targets
|
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
||||||
* the tapped widget's own id rather than `updateAll`, whose provider-name lookup
|
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
||||||
* is empty in a process a tap woke from cold — see `ShiftMonthAction` (#18).
|
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
||||||
|
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
||||||
|
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
||||||
*/
|
*/
|
||||||
class RefreshAgendaAction : ActionCallback {
|
class RefreshAgendaAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
@@ -148,7 +151,7 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
/**
|
/**
|
||||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
* stays well inside the binder transaction limit regardless of range and calendar
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
|
||||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
* risking a failed update for.
|
* risking a failed update for.
|
||||||
*/
|
*/
|
||||||
@@ -419,7 +422,11 @@ private fun EventRow(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
style = TextStyle(
|
||||||
|
color = titleColor,
|
||||||
|
fontSize = metrics.eventTitle,
|
||||||
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -32,6 +33,7 @@ import androidx.glance.layout.Box
|
|||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
import androidx.glance.layout.Row
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxHeight
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
import androidx.glance.layout.fillMaxWidth
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
@@ -96,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
|
|||||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
|
*
|
||||||
|
* Everything here is written to keep the published `RemoteViews` small. A widget
|
||||||
|
* update reaches the launcher as a *oneway* binder call, and the host's async
|
||||||
|
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
|
||||||
|
* the system drops the whole host, freezing every widget on the home screen until
|
||||||
|
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||||
|
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||||
|
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||||
|
* row here emits only what it fills.
|
||||||
*/
|
*/
|
||||||
class MonthWidget : GlanceAppWidget() {
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||||
|
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||||
|
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||||
|
* Exact costs one serialised copy per host size, which is affordable now that
|
||||||
|
* a rendering is ~190 views rather than ~740.
|
||||||
|
*/
|
||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
@@ -295,29 +314,75 @@ private fun WeekRow(
|
|||||||
colW: Dp,
|
colW: Dp,
|
||||||
modifier: GlanceModifier,
|
modifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
val context = LocalContext.current
|
||||||
// Day numbers.
|
val hidden = week.hiddenPerDay()
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||||
|
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||||
|
// every cell in the column carrying a click target of its own. The content
|
||||||
|
// above is not clickable, so touches fall through to this layer; only an
|
||||||
|
// event bar opts out to open its own detail.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
week.days.forEach { date ->
|
week.days.forEach { date ->
|
||||||
DayNumber(
|
Box(
|
||||||
date = date,
|
modifier = GlanceModifier
|
||||||
isToday = date == today,
|
.width(colW)
|
||||||
inMonth = date.month == currentMonth,
|
.fillMaxHeight()
|
||||||
colW = colW,
|
.clickable(openDayAction(context, date)),
|
||||||
)
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
// Day numbers. Fixed height so every week's number row lines up whether
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
// or not it holds today's larger pill.
|
||||||
repeat(MAX_LANES) { lane ->
|
Row(
|
||||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
week.days.forEach { date ->
|
||||||
|
DayNumber(
|
||||||
|
date = date,
|
||||||
|
isToday = date == today,
|
||||||
|
inMonth = date.month == currentMonth,
|
||||||
|
colW = colW,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
|
// Lanes past the last one this week fills emit nothing at all.
|
||||||
|
repeat(week.usedLanes()) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||||
}
|
}
|
||||||
OverflowRow(week = week, colW = colW)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||||
|
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||||
|
* per day and most weeks leave the last two empty.
|
||||||
|
*/
|
||||||
|
private fun MonthWeek.usedLanes(): Int {
|
||||||
|
for (lane in MAX_LANES - 1 downTo 0) {
|
||||||
|
val filled = spans.any { it.lane == lane } ||
|
||||||
|
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||||
|
if (filled) return lane + 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||||
|
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||||
|
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
|
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||||
|
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
@@ -327,64 +392,93 @@ private fun WeekRow(
|
|||||||
private fun openDayAction(context: Context, date: LocalDate) =
|
private fun openDayAction(context: Context, date: LocalDate) =
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||||
|
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||||
|
* Today keeps its filled circle, which does need the Box.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val style = TextStyle(
|
||||||
Box(
|
color = when {
|
||||||
modifier = GlanceModifier
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
.width(colW)
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
.clickable(openDayAction(context, date)),
|
},
|
||||||
contentAlignment = Alignment.Center,
|
fontSize = 11.sp,
|
||||||
) {
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!isToday) {
|
||||||
|
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||||
|
.background(GlanceTheme.colors.primary),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = date.day.toString(), style = style)
|
||||||
text = date.day.toString(),
|
|
||||||
style = TextStyle(
|
|
||||||
color = when {
|
|
||||||
isToday -> GlanceTheme.colors.onPrimary
|
|
||||||
inMonth -> GlanceTheme.colors.onSurface
|
|
||||||
else -> GlanceTheme.colors.onSurfaceVariant
|
|
||||||
},
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||||
|
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||||
|
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||||
|
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||||
|
*/
|
||||||
|
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||||
|
val cells = mutableListOf<LaneCell>()
|
||||||
|
var gap = 0
|
||||||
|
var col = 0
|
||||||
|
fun closeGap() {
|
||||||
|
if (gap > 0) cells += LaneCell(null, gap)
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
while (col < 7) {
|
||||||
|
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||||
|
when {
|
||||||
|
span != null -> {
|
||||||
|
closeGap()
|
||||||
|
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||||
|
col = span.endCol + 1
|
||||||
|
}
|
||||||
|
timed != null -> {
|
||||||
|
closeGap()
|
||||||
|
cells += LaneCell(timed, 1)
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
gap += 1
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeGap()
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
week.laneCells(lane).forEach { cell ->
|
||||||
while (col < 7) {
|
if (cell.event == null) {
|
||||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||||
if (span != null) {
|
|
||||||
val cols = span.endCol - col + 1
|
|
||||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
|
||||||
col = span.endCol + 1
|
|
||||||
} else {
|
} else {
|
||||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
SpanBar(
|
||||||
if (timed != null) {
|
event = cell.event,
|
||||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
dark = dark,
|
||||||
} else {
|
soften = soften,
|
||||||
// Empty lane cell: a tap opens that day, so blank space in a
|
width = colW * cell.cols,
|
||||||
// day column is a day-open target just like the number is.
|
)
|
||||||
Box(
|
|
||||||
GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.clickable(openDayAction(context, week.days[col])),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
col += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,41 +517,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
|||||||
Text(
|
Text(
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
style = TextStyle(
|
||||||
|
color = ColorProvider(eventInk(fill)),
|
||||||
|
fontSize = 9.sp,
|
||||||
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
|
),
|
||||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||||
val context = LocalContext.current
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, date ->
|
var gap = 0
|
||||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
hidden.forEach { count ->
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
if (count == 0) {
|
||||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
gap += 1
|
||||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
return@forEach
|
||||||
// The overflow row is part of the day column too: tapping it (whether
|
|
||||||
// it shows "+N" or is blank) opens that day, same as the app.
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.clickable(openDayAction(context, date)),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (hidden > 0) {
|
|
||||||
Text(
|
|
||||||
text = "+$hidden",
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
|
||||||
modifier = GlanceModifier.padding(start = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (gap > 0) {
|
||||||
|
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "+$count",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -693,11 +693,15 @@
|
|||||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||||
<string name="import_done_added_label">Added</string>
|
<string name="import_done_added_label">Added</string>
|
||||||
<string name="import_done_skipped_label">Duplicates</string>
|
<string name="import_done_skipped_label">Duplicates</string>
|
||||||
|
<string name="import_done_failed_label">Not added</string>
|
||||||
|
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
|
||||||
<string name="import_close">Close</string>
|
<string name="import_close">Close</string>
|
||||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||||
|
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
|
||||||
|
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
|
||||||
<string name="import_button">Import</string>
|
<string name="import_button">Import</string>
|
||||||
<plurals name="import_title_count">
|
<plurals name="import_title_count">
|
||||||
<item quantity="one">Importing %d event</item>
|
<item quantity="one">Importing %d event</item>
|
||||||
@@ -719,6 +723,10 @@
|
|||||||
<item quantity="one">Skipped %d already in this calendar.</item>
|
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||||
<item quantity="other">Skipped %d already in this calendar.</item>
|
<item quantity="other">Skipped %d already in this calendar.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="import_done_failed">
|
||||||
|
<item quantity="one">%d event couldn\'t be added.</item>
|
||||||
|
<item quantity="other">%d events couldn\'t be added.</item>
|
||||||
|
</plurals>
|
||||||
<!-- Launcher long-press shortcuts -->
|
<!-- Launcher long-press shortcuts -->
|
||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|||||||
@@ -801,6 +801,42 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents carries on past an event the provider rejects`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
// A foreign export can hold a row the provider throws on outright; that
|
||||||
|
// must cost the user one event, not the whole file (Codeberg #225).
|
||||||
|
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
val events = listOf(
|
||||||
|
parsedEvent("a@x", summary = "good"),
|
||||||
|
parsedEvent("b@x", summary = "bad"),
|
||||||
|
parsedEvent("c@x", summary = "good"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||||
|
|
||||||
|
assertThat(summary.imported).isEqualTo(2)
|
||||||
|
assertThat(summary.failed).isEqualTo(1)
|
||||||
|
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents looks the target palette up once, not per event`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
var lookups = 0
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventColorPaletteResult = { lookups++; emptyList() }
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
|
||||||
|
|
||||||
|
assertThat(lookups).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
@@ -825,9 +861,10 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||||
|
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = "E",
|
summary = summary,
|
||||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
eventDetailResult(eventId)
|
eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
eventColorPaletteResult(calendarId)
|
eventColorPaletteResult(calendarId)
|
||||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
override fun exportableEvents(
|
||||||
|
calendarIds: Set<Long>?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<IcsEvent> {
|
||||||
lastExportableEventsCalendarIds = calendarIds
|
lastExportableEventsCalendarIds = calendarIds
|
||||||
return exportableEventsResult
|
return exportableEventsResult
|
||||||
}
|
}
|
||||||
@@ -91,8 +94,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||||
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||||
|
|
||||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||||
|
val failingImportSummaries = mutableSetOf<String>()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
|
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||||
importedEvents += event to calendarId
|
importedEvents += event to calendarId
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class IcsExportMapperTest {
|
class IcsExportMapperTest {
|
||||||
|
|
||||||
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
||||||
)
|
)
|
||||||
|
|
||||||
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = listOf(10),
|
||||||
|
calendarName = "Personal",
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
assertThat(event.uid).isEqualTo("abc@host")
|
assertThat(event.uid).isEqualTo("abc@host")
|
||||||
assertThat(event.summary).isEqualTo("Standup")
|
assertThat(event.summary).isEqualTo("Standup")
|
||||||
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
||||||
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||||
@@ -65,6 +74,32 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
|
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder is exported as its whole-day lead time`() {
|
||||||
|
// The raw offset encodes the firing time and is normally negative; left
|
||||||
|
// raw it would be written as a trigger after the event and dropped, so
|
||||||
|
// the backup would come back with no reminder at all.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 1L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Anna Birthday",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
|
||||||
|
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 1,
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
|
||||||
|
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
|
||||||
|
|
||||||
|
assertThat(onTheDay.reminderMinutes).containsExactly(0)
|
||||||
|
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NINE_AM = 9 * 60
|
||||||
|
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ImportedEventValuesTest {
|
||||||
|
|
||||||
|
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
|
||||||
|
|
||||||
|
private fun allDay(
|
||||||
|
rrule: String? = null,
|
||||||
|
exDates: List<String> = emptyList(),
|
||||||
|
color: Int? = null,
|
||||||
|
days: Int = 1,
|
||||||
|
) = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "Anna Birthday",
|
||||||
|
start = dayStart,
|
||||||
|
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
exDates = exDates,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
|
||||||
|
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-off carries DTEND and no recurrence`() {
|
||||||
|
val v = values(allDay())
|
||||||
|
assertThat(v[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
|
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a recurring row carries DURATION instead of DTEND`() {
|
||||||
|
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
|
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series is never zero days long`() {
|
||||||
|
// A degenerate span would expand into no instances at all — the series
|
||||||
|
// would simply not exist (Codeberg #225).
|
||||||
|
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATEs ride along with the recurrence`() {
|
||||||
|
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
|
||||||
|
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATEs are not written without a recurrence to exclude from`() {
|
||||||
|
val v = values(allDay(exDates = listOf("20260820")))
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an imported colour is written raw when the account publishes no palette`() {
|
||||||
|
val v = values(allDay(color = -2818048))
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an imported colour snaps to the nearest published palette key`() {
|
||||||
|
val palette = listOf(
|
||||||
|
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
|
||||||
|
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
|
||||||
|
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
|
||||||
|
)
|
||||||
|
// Tomato — visually an orangered, nothing like the blue or the green.
|
||||||
|
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
|
||||||
|
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
|
||||||
|
// A raw colour alongside a key is what a palette calendar rejects.
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event with no colour touches neither colour column`() {
|
||||||
|
val v = values(allDay())
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed event keeps its own zone and a seconds duration`() {
|
||||||
|
val start = Instant.parse("2026-08-19T08:00:00Z")
|
||||||
|
val event = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "Standup",
|
||||||
|
start = start,
|
||||||
|
end = Instant.parse("2026-08-19T08:15:00Z"),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "Europe/Berlin",
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
)
|
||||||
|
|
||||||
|
val v = values(event)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||||
|
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -17,6 +18,7 @@ class InstanceMapperTest {
|
|||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
location: String? = null,
|
location: String? = null,
|
||||||
|
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
InstanceProjection.IDX_INSTANCE_ID to instanceId,
|
InstanceProjection.IDX_INSTANCE_ID to instanceId,
|
||||||
InstanceProjection.IDX_EVENT_ID to eventId,
|
InstanceProjection.IDX_EVENT_ID to eventId,
|
||||||
@@ -28,6 +30,7 @@ class InstanceMapperTest {
|
|||||||
InstanceProjection.IDX_EVENT_COLOR to eventColor,
|
InstanceProjection.IDX_EVENT_COLOR to eventColor,
|
||||||
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
|
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||||
InstanceProjection.IDX_LOCATION to location,
|
InstanceProjection.IDX_LOCATION to location,
|
||||||
|
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -90,4 +93,20 @@ class InstanceMapperTest {
|
|||||||
val inst = reader(location = "Berlin").toEventInstance()
|
val inst = reader(location = "Berlin").toEventInstance()
|
||||||
assertThat(inst!!.location).isEqualTo("Berlin")
|
assertThat(inst!!.location).isEqualTo("Berlin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a declined invitation is marked, any other answer is not`() {
|
||||||
|
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
|
||||||
|
assertThat(
|
||||||
|
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
|
||||||
|
.toEventInstance()!!.isDeclined,
|
||||||
|
).isTrue()
|
||||||
|
listOf(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||||
|
).forEach { status ->
|
||||||
|
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IcsColorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a raw Android colour int round-trips`() {
|
||||||
|
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a CSS3 name resolves opaque`() {
|
||||||
|
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hex forms are accepted with and without alpha`() {
|
||||||
|
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a colour with no alpha byte is made opaque`() {
|
||||||
|
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
|
||||||
|
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unusable values resolve to null`() {
|
||||||
|
assertThat(parseIcsColorValue(null)).isNull()
|
||||||
|
assertThat(parseIcsColorValue("")).isNull()
|
||||||
|
// Fossify interpolates a nullable calendar straight into the property.
|
||||||
|
assertThat(parseIcsColorValue("null")).isNull()
|
||||||
|
assertThat(parseIcsColorValue("chartreusey")).isNull()
|
||||||
|
assertThat(parseIcsColorValue("#abc")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
|
||||||
|
*
|
||||||
|
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
|
||||||
|
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
|
||||||
|
*
|
||||||
|
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
|
||||||
|
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
|
||||||
|
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
|
||||||
|
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
|
||||||
|
* following midnight. Shifting those by a day would corrupt them.
|
||||||
|
*
|
||||||
|
* The exception — and the whole of #225 — is events mirrored from Contacts.
|
||||||
|
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
|
||||||
|
* lands back on the starting day and they export zero length.
|
||||||
|
*/
|
||||||
|
class IcsFossifyImportTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||||
|
|
||||||
|
private fun fossify(vararg body: String) = (
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
|
||||||
|
"VERSION:2.0",
|
||||||
|
) + body + listOf("END:VCALENDAR")
|
||||||
|
).joinToString("\r\n")
|
||||||
|
|
||||||
|
private fun days(event: ParsedIcsEvent): Long =
|
||||||
|
(event.end - event.start).inWholeMilliseconds / 86_400_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event keeps the exact span the file gives it`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"UID:abc123",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART;VALUE=DATE:19900412",
|
||||||
|
"DTEND;VALUE=DATE:19900413",
|
||||||
|
"X-FOSSIFY-MISSING-YEAR:0",
|
||||||
|
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.isAllDay).isTrue()
|
||||||
|
assertThat(days(event)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contact-mirrored birthdays export zero length and must not vanish`() {
|
||||||
|
// The reported failure (#225). Fossify mirrors Contacts birthdays and
|
||||||
|
// anniversaries with startTS == endTS, so the exporter's +12h rounds
|
||||||
|
// back to the starting day: DTSTART == DTEND. Read literally these are
|
||||||
|
// yearly series of zero-length occurrences, which the provider expands
|
||||||
|
// into nothing at all — the birthdays import "successfully" and are
|
||||||
|
// then nowhere to be seen.
|
||||||
|
val text = checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
|
||||||
|
val result = parser.parse(text)
|
||||||
|
|
||||||
|
assertThat(result.events).hasSize(3)
|
||||||
|
result.events.forEach {
|
||||||
|
assertThat(it.isAllDay).isTrue()
|
||||||
|
assertThat(days(it)).isEqualTo(1)
|
||||||
|
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
|
||||||
|
// Their all-day reminder encoding: "on the day", not midnight UTC.
|
||||||
|
assertThat(it.semanticReminderMinutes()).containsExactly(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real Fossify holiday file imports unchanged`() {
|
||||||
|
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
|
||||||
|
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
|
||||||
|
// it — the file is plain, conformant iCalendar.
|
||||||
|
val text = checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
|
||||||
|
val result = parser.parse(text)
|
||||||
|
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
|
||||||
|
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
|
||||||
|
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event with no DTEND lasts one day`() {
|
||||||
|
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(days(result.events.single())).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero-length all-day event is widened rather than left to vanish`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260819",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(days(result.events.single())).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task is imported as an event and reported`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Pay rent",
|
||||||
|
"DTSTART;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val task = result.events.single()
|
||||||
|
assertThat(task.isTask).isTrue()
|
||||||
|
assertThat(task.summary).isEqualTo("Pay rent")
|
||||||
|
assertThat(days(task)).isEqualTo(1)
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task dated only by DUE still imports`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:File taxes",
|
||||||
|
"DUE;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().summary).isEqualTo("File taxes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the per-event colour wins over the calendar colour`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"COLOR:tomato",
|
||||||
|
"X-FOSSIFY-EVENT-COLOR:-2818048",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-2818048)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the calendar colour stands in when the event has none of its own`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the legacy Simple Calendar colour spellings are read too`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-SMT-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
).joinToString("\r\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
|
||||||
|
// Both properties interpolate a nullable calendar straight into the value.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:null",
|
||||||
|
"CATEGORIES:null",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.color).isNull()
|
||||||
|
assertThat(event.calendarName).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CATEGORIES names the source calendar`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"CATEGORIES:Birthdays",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reminder written as a positive trigger is not read as a lead time`() {
|
||||||
|
// The exporter flips the sign for reminders stored below -1
|
||||||
|
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
|
||||||
|
// point after the start. On an all-day event that means a time of day.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:P0DT9H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.reminderMinutes).containsExactly(-540)
|
||||||
|
assertThat(event.semanticReminderMinutes()).containsExactly(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
|
||||||
|
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
|
||||||
|
// time puts "1 day before" only six hours ahead of the UTC midnight.
|
||||||
|
// Rounding to the nearest day would read that as "on the day".
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:-P0DT6H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder rounds to whole days before`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
// 15h before midnight == "1 day before at 09:00".
|
||||||
|
"TRIGGER:-P0DT15H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed reminder keeps its exact lead time`() {
|
||||||
|
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:-P0DT0H10M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series' EXDATE day codes carry over`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"EXDATE:20260822",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
|
||||||
|
// They store excluded occurrences as day codes and write them out that
|
||||||
|
// way even for a timed series, where the property alone is ambiguous.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Gym",
|
||||||
|
"DTSTART:20260819T170000Z",
|
||||||
|
"DTEND:20260819T180000Z",
|
||||||
|
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a floating EXDATE is read in the series' zone, not the device's`() {
|
||||||
|
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
|
||||||
|
// value is New York wall time. Reading it in the device's Berlin zone
|
||||||
|
// would land on an instant no occurrence has, and the excluded
|
||||||
|
// occurrence would come back.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART;TZID=America/New_York:20260819T100000",
|
||||||
|
"DTEND;TZID=America/New_York:20260819T103000",
|
||||||
|
"RRULE:FREQ=WEEKLY",
|
||||||
|
"EXDATE:20260826T100000",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATE is dropped along with an unusable recurrence rule`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Gym",
|
||||||
|
"DTSTART:20260819T170000Z",
|
||||||
|
"DTEND:20260819T180000Z",
|
||||||
|
"RRULE:INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.recurrenceRule).isNull()
|
||||||
|
assertThat(event.exDates).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a whole export imports every component`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"UID:abc123",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"CATEGORIES:Birthdays",
|
||||||
|
"LAST-MODIFIED:20260101T120000Z",
|
||||||
|
"TRANSP:TRANSPARENT",
|
||||||
|
"DTSTART;VALUE=DATE:19900412",
|
||||||
|
"DTEND;VALUE=DATE:19900413",
|
||||||
|
"X-FOSSIFY-MISSING-YEAR:0",
|
||||||
|
"DTSTAMP:20260819T100000Z",
|
||||||
|
"CLASS:PUBLIC",
|
||||||
|
"STATUS:CONFIRMED",
|
||||||
|
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||||
|
"END:VEVENT",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"UID:def456",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
|
||||||
|
"END:VEVENT",
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Pay rent",
|
||||||
|
"UID:ghi789",
|
||||||
|
"DTSTART;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
|
||||||
|
// Nothing may reach the provider as a zero-length all-day row.
|
||||||
|
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IcsRecurrenceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a well-formed rule is unchanged`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the RRULE prefix is stripped`() {
|
||||||
|
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list part is dropped`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY="))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH="))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a nonsensical INTERVAL is dropped, not fatal`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rule without a usable FREQ is unsalvageable`() {
|
||||||
|
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull()
|
||||||
|
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull()
|
||||||
|
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull()
|
||||||
|
assertThat(sanitizeRrule("")).isNull()
|
||||||
|
assertThat(sanitizeRrule(null)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FREQ is normalised to upper case`() {
|
||||||
|
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown parts pass through untouched`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
|
||||||
|
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-opening an occurrence must re-read it (#196): the view model outlives the
|
||||||
|
* sheet, so an edit that changed no time would otherwise show the pre-save row.
|
||||||
|
* The re-read stays silent — the loaded content must not blink back to the
|
||||||
|
* skeleton on the way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class EventDetailViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = UnconfinedTestDispatcher()
|
||||||
|
|
||||||
|
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||||
|
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
private val beginMillis = 1_781_164_800_000L
|
||||||
|
private val endMillis = beginMillis + 3_600_000L
|
||||||
|
|
||||||
|
private fun detail(description: String?) = EventDetail(
|
||||||
|
instance = EventInstance(
|
||||||
|
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||||
|
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(endMillis),
|
||||||
|
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||||
|
),
|
||||||
|
description = description, organizer = null, attendees = emptyList(), rrule = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
|
||||||
|
val prefs = CalendarPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val settings = SettingsPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||||
|
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||||
|
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(
|
||||||
|
CalendarSource(
|
||||||
|
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
|
||||||
|
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
eventDetailResult = { detail(description()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
var stored: String? = null
|
||||||
|
val vm = viewModel(tempDir, fakeSource { stored })
|
||||||
|
val collector = launch(Job()) { vm.state.collect {} }
|
||||||
|
|
||||||
|
vm.open(42L, beginMillis, endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull()
|
||||||
|
|
||||||
|
// The edit screen saved a description; the tapped occurrence is unchanged.
|
||||||
|
stored = "Bring the roadmap"
|
||||||
|
vm.open(42L, beginMillis, endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat((vm.state.value as EventDetailUiState.Success).detail.description)
|
||||||
|
.isEqualTo("Bring the roadmap")
|
||||||
|
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
val vm = viewModel(tempDir, fakeSource { null })
|
||||||
|
val seen = mutableListOf<EventDetailUiState>()
|
||||||
|
val collector = launch(Job()) { vm.state.collect { seen += it } }
|
||||||
|
|
||||||
|
vm.open(42L, beginMillis, endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java)
|
||||||
|
|
||||||
|
seen.clear()
|
||||||
|
vm.open(42L, beginMillis, endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(seen).doesNotContain(EventDetailUiState.Loading)
|
||||||
|
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Which calendar a bulk import lands on before the user touches the picker. */
|
||||||
|
class ImportTargetTest {
|
||||||
|
|
||||||
|
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
accountName = "acc@local",
|
||||||
|
accountType = if (local) "LOCAL" else "com.google",
|
||||||
|
color = 0,
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
isLocal = local,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val calendars = listOf(
|
||||||
|
cal(1L, "Work"),
|
||||||
|
cal(2L, "Personal", local = true),
|
||||||
|
cal(3L, "Birthdays", local = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a calendar named like the file's wins`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the name match ignores case`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unmatched name falls back to the first local calendar`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no name at all falls back to the first local calendar`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with no local calendars the first of any kind is taken`() {
|
||||||
|
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list preselects nothing`() {
|
||||||
|
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal file
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Fossify//NONSGML Event Calendar//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Anna Schmidt
|
||||||
|
UID:101
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Birthdays
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:19900412
|
||||||
|
DTEND;VALUE=DATE:19900412
|
||||||
|
X-FOSSIFY-MISSING-YEAR:0
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Opa
|
||||||
|
UID:102
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Birthdays
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:19700603
|
||||||
|
DTEND;VALUE=DATE:19700603
|
||||||
|
X-FOSSIFY-MISSING-YEAR:1
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Hochzeitstag
|
||||||
|
UID:103
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Anniversaries
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:20050917
|
||||||
|
DTEND;VALUE=DATE:20050917
|
||||||
|
X-FOSSIFY-MISSING-YEAR:0
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal file
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
PRODID:Fossify Calendar Holiday Generator
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-PUBLISHED-TTL:PT1H
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
|
||||||
|
SUMMARY:Neujahr
|
||||||
|
DTSTAMP:20260526T132315Z
|
||||||
|
DTSTART;VALUE=DATE:19800101
|
||||||
|
DTEND;VALUE=DATE:19800102
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
|
||||||
|
SUMMARY:Heilige Drei Könige
|
||||||
|
DTSTAMP:20260526T132315Z
|
||||||
|
DTSTART;VALUE=DATE:19800106
|
||||||
|
DTEND;VALUE=DATE:19800107
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -346,6 +346,69 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
|
|||||||
be copied, because our failure mode includes a broadcast that arrives with no
|
be copied, because our failure mode includes a broadcast that arrives with no
|
||||||
alert row behind it.
|
alert row behind it.
|
||||||
|
|
||||||
|
## Importing foreign `.ics`
|
||||||
|
|
||||||
|
`IcsParser` is deliberately liberal, because the files it is handed were written
|
||||||
|
by other people's calendars. What that costs us, learned from reading the Simple
|
||||||
|
Calendar / Fossify exporter (#225):
|
||||||
|
|
||||||
|
**An all-day event may never reach the provider zero days long.** This is what
|
||||||
|
#225 was: Fossify mirrors Contacts birthdays and anniversaries with
|
||||||
|
`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)`
|
||||||
|
rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally
|
||||||
|
that is a yearly series of zero-length occurrences, which the provider expands
|
||||||
|
into no instances at all — the import reports success and the birthdays are
|
||||||
|
nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an
|
||||||
|
all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with
|
||||||
|
no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day
|
||||||
|
`DURATION` at `P1D`.
|
||||||
|
|
||||||
|
Do **not** generalise that into correcting the producer's `DTEND` by sniffing its
|
||||||
|
`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a
|
||||||
|
UI-created all-day event at *noon* of its last day
|
||||||
|
(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row
|
||||||
|
came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight.
|
||||||
|
Shifting those would push every one of them out by a day. The holiday files
|
||||||
|
bundled inside Fossify are a second trap: their `PRODID` reads
|
||||||
|
`Fossify Calendar Holiday Generator` and their content is plain conformant
|
||||||
|
iCalendar. Both that file and a contact-birthday export are kept as fixtures in
|
||||||
|
`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the
|
||||||
|
conformant one.
|
||||||
|
|
||||||
|
**One bad event must not cost the file.** The provider validates `RRULE` through
|
||||||
|
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
|
||||||
|
read. `sanitizeRrule` drops empty and malformed parts before the write, and
|
||||||
|
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
||||||
|
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
|
||||||
|
"couldn't read this file".
|
||||||
|
|
||||||
|
Smaller dialect handling: `VTODO` components import as events (Calendula models
|
||||||
|
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands
|
||||||
|
in for the `X-WR-CALNAME` Fossify never writes and preselects a target calendar
|
||||||
|
of that name, bare `EXDATE` day codes on a timed series are resolved against the
|
||||||
|
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
|
||||||
|
to `DTSTART`'s zone, not the device's), and a `VALARM` trigger pointing *after*
|
||||||
|
the start — which is how that family encodes "on the day at 09:00" — is read as
|
||||||
|
zero days before rather than clamped to a lead time of zero.
|
||||||
|
|
||||||
|
All-day reminder offsets are **whole days, rounded up** in both directions. A
|
||||||
|
file's raw offset is `days × 1440 − timeOfDay`, so rounding to the nearest day
|
||||||
|
would drop a day for every producer whose all-day notifications fire after noon
|
||||||
|
("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
|
||||||
|
mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
|
||||||
|
into it and is normally *negative*, which the writer used to discard as a trigger
|
||||||
|
after the event — so `toIcsEvent` decodes it back through
|
||||||
|
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||||
|
its own setting.
|
||||||
|
|
||||||
|
Colour arrives as a raw ARGB from an app with no idea which account it is landing
|
||||||
|
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
|
||||||
|
`buildImportedEventValues` snaps the imported colour to the nearest published key
|
||||||
|
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
|
||||||
|
palette. The single-event review form deliberately keeps no colour at all: its
|
||||||
|
first question is which calendar to use, and answering it clears the colour
|
||||||
|
anyway.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
||||||
|
|||||||
8
fastlane/metadata/android/ar/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/ar/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
إصلاحات
|
||||||
|
• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه.
|
||||||
|
• التعديل يظهر فور إعادة فتح الحدث.
|
||||||
|
• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل.
|
||||||
|
• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع.
|
||||||
|
|
||||||
|
تغييرات
|
||||||
|
• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به.
|
||||||
8
fastlane/metadata/android/de-DE/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/de-DE/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Behoben
|
||||||
|
• Ein Tippen auf einen Termin in der Monatsansicht öffnet ihn statt des Tages.
|
||||||
|
• Eine Änderung ist sofort sichtbar, wenn der Termin neu geöffnet wird.
|
||||||
|
• Die Pfeile des Monats-Widgets sterben nicht mehr nach wenigen Tipps – eine zu große Aktualisierung riss den Widget-Host mit.
|
||||||
|
• „Heute“ springt in nahtlosen Wochen genau auf die aktuelle Woche; Wochen- und Tagesansicht haben rechts wieder Abstand.
|
||||||
|
|
||||||
|
Geändert
|
||||||
|
• Ein abgelehnter Termin ist überall durchgestrichen und erinnert nicht mehr.
|
||||||
8
fastlane/metadata/android/en-GB/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/en-GB/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Fixed
|
||||||
|
• Tapping an event in month view now opens the event instead of its day.
|
||||||
|
• An edit shows straight away when you re-open the event.
|
||||||
|
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
||||||
|
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
• A meeting you declined is struck through everywhere and no longer reminds you.
|
||||||
8
fastlane/metadata/android/en-US/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Fixed
|
||||||
|
• Tapping an event in month view now opens the event instead of its day.
|
||||||
|
• An edit shows straight away when you re-open the event.
|
||||||
|
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
||||||
|
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
• A meeting you declined is struck through everywhere and no longer reminds you.
|
||||||
8
fastlane/metadata/android/es-ES/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/es-ES/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Corregido
|
||||||
|
• Tocar un evento en la vista de mes abre el evento, no su día.
|
||||||
|
• Un cambio se ve en cuanto vuelves a abrir el evento.
|
||||||
|
• Las flechas del widget de mes ya no se quedan muertas tras unos toques: una actualización demasiado grande tumbaba el host de widgets del launcher.
|
||||||
|
• «Hoy» en semanas continuas llega justo a la semana actual, y las vistas de día y semana vuelven a tener margen a la derecha.
|
||||||
|
|
||||||
|
Cambios
|
||||||
|
• Un evento que has rechazado aparece tachado en todas partes y ya no avisa.
|
||||||
8
fastlane/metadata/android/fr-FR/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/fr-FR/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Corrigé
|
||||||
|
• Appuyer sur un événement en vue mois ouvre l'événement, pas sa journée.
|
||||||
|
• Une modification apparaît dès que l'événement est rouvert.
|
||||||
|
• Les flèches du widget mois ne meurent plus après quelques appuis : une mise à jour trop lourde emportait l'hôte de widgets du lanceur.
|
||||||
|
• « Aujourd'hui » en semaines continues arrive pile sur la semaine en cours ; les vues jour et semaine ont de nouveau une marge à droite.
|
||||||
|
|
||||||
|
Modifié
|
||||||
|
• Un événement refusé est barré partout et ne vous rappelle plus rien.
|
||||||
8
fastlane/metadata/android/it-IT/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/it-IT/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Corretto
|
||||||
|
• Toccare un evento nella vista mese apre l'evento, non il suo giorno.
|
||||||
|
• Una modifica si vede subito riaprendo l'evento.
|
||||||
|
• Le frecce del widget mese non muoiono più dopo pochi tocchi: un aggiornamento troppo grande abbatteva l'host dei widget del launcher.
|
||||||
|
• «Oggi» nelle settimane continue arriva esattamente sulla settimana corrente, e le viste giorno e settimana hanno di nuovo un margine a destra.
|
||||||
|
|
||||||
|
Modifiche
|
||||||
|
• Un evento rifiutato è barrato ovunque e non invia più promemoria.
|
||||||
8
fastlane/metadata/android/pl-PL/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/pl-PL/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Poprawki
|
||||||
|
• Dotknięcie wydarzenia w widoku miesiąca otwiera wydarzenie, a nie jego dzień.
|
||||||
|
• Zmiana jest widoczna od razu po ponownym otwarciu wydarzenia.
|
||||||
|
• Strzałki widżetu miesiąca nie zamierają już po kilku dotknięciach – zbyt duża aktualizacja kładła host widżetów launchera.
|
||||||
|
• „Dziś” w ciągłych tygodniach trafia dokładnie w bieżący tydzień, a widoki dnia i tygodnia znów mają margines z prawej.
|
||||||
|
|
||||||
|
Zmiany
|
||||||
|
• Odrzucone wydarzenie jest wszędzie przekreślone i nie przypomina o sobie.
|
||||||
8
fastlane/metadata/android/pt-BR/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/pt-BR/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Corrigido
|
||||||
|
• Tocar em um evento na visualização de mês abre o evento, e não o seu dia.
|
||||||
|
• Uma alteração aparece assim que o evento é reaberto.
|
||||||
|
• As setas do widget de mês não morrem mais depois de alguns toques: uma atualização grande demais derrubava o host de widgets do launcher.
|
||||||
|
• "Hoje" nas semanas contínuas chega exatamente na semana atual, e as visualizações de dia e semana voltaram a ter margem à direita.
|
||||||
|
|
||||||
|
Alterações
|
||||||
|
• Um evento recusado fica riscado em todo lugar e não lembra mais você.
|
||||||
8
fastlane/metadata/android/pt-PT/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/pt-PT/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Corrigido
|
||||||
|
• Tocar num evento na vista de mês abre o evento e não o seu dia.
|
||||||
|
• Uma alteração aparece assim que o evento é reaberto.
|
||||||
|
• As setas do widget de mês já não morrem ao fim de alguns toques: uma atualização demasiado grande derrubava o anfitrião de widgets do launcher.
|
||||||
|
• «Hoje» nas semanas contínuas chega mesmo à semana atual, e as vistas de dia e semana voltam a ter margem à direita.
|
||||||
|
|
||||||
|
Alterações
|
||||||
|
• Um evento recusado fica riscado em todo o lado e deixa de lembrar.
|
||||||
8
fastlane/metadata/android/ru-RU/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/ru-RU/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Исправлено
|
||||||
|
• Нажатие на событие в виде месяца открывает событие, а не его день.
|
||||||
|
• Изменение видно сразу при повторном открытии события.
|
||||||
|
• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера.
|
||||||
|
• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа.
|
||||||
|
|
||||||
|
Изменения
|
||||||
|
• Отклонённое событие везде зачёркнуто и больше не напоминает о себе.
|
||||||
8
fastlane/metadata/android/zh-CN/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/zh-CN/changelogs/21902.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
修复
|
||||||
|
• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。
|
||||||
|
• 编辑后再次打开事件,改动会立即显示。
|
||||||
|
• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。
|
||||||
|
• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。
|
||||||
|
|
||||||
|
变更
|
||||||
|
• 已拒绝的事件在各处均以删除线标记,并且不再提醒。
|
||||||
Reference in New Issue
Block a user