Compare commits
9 Commits
main
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 60aa889c57 | |||
| 3c9767387b |
@@ -3,13 +3,15 @@ name: Translations
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||
# required check pending forever and blocks the merge of any code-only PR into a
|
||||
# release/* branch. The check itself is cheap and simply passes when the
|
||||
# committed translations are consistent, so always running it costs nothing.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/src/main/res/values*/strings.xml'
|
||||
- 'app/src/main/res/xml/locales_config.xml'
|
||||
- 'scripts/check_translations.py'
|
||||
- '.gitea/workflows/translations.yaml'
|
||||
|
||||
concurrency:
|
||||
group: translations-${{ github.ref }}
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||
after exactly the delay you want instead of only a preset one ([#40]).
|
||||
- Move an event to another calendar. When editing an existing event, the
|
||||
calendar row is now tappable — pick a different calendar and saving moves the
|
||||
event across, instead of having to delete it and recreate it elsewhere.
|
||||
Recurring series move as a whole, keeping their individually-edited and
|
||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||
the target and removes the original — the same approach other calendar apps
|
||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
reminder fired ahead of time — say, the day before — used to show only the
|
||||
event's time, making it look like it was happening now. The notification now
|
||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||
week, or the date for anything further out. Thanks to @moonj for the report
|
||||
([#46]).
|
||||
|
||||
## [2.14.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
@@ -874,3 +898,6 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||
|
||||
@@ -18,6 +18,8 @@ import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
@@ -176,6 +178,25 @@ interface CalendarDataSource {
|
||||
allDayReminderTimeMinutes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Move an event (for recurring events: the whole series, with its modified
|
||||
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
||||
* replayed, the [original]→[updated] field edits applied, then the source
|
||||
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||
* before that rolls the new event back, leaving the original untouched.
|
||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
@@ -876,6 +897,190 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
||||
// target calendar and delete the source. Everything that identifies or
|
||||
// hangs off the event is copied first; the source row is removed only
|
||||
// once the copy (including its exceptions) is complete — a failure
|
||||
// anywhere before that rolls the new event back, so the move is
|
||||
// all-or-nothing and never leaves a half-copied duplicate.
|
||||
val master = queryMoveMaster(eventId)
|
||||
?: throw WriteFailedException("read event to move id=$eventId")
|
||||
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||
// recognise the moved event; mint one only if the source carried none.
|
||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
||||
val newEventId = resolver.insert(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||
)?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||
try {
|
||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||
insertAttendees(newEventId, editableAttendees(eventId))
|
||||
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
||||
// Apply the user's field edits exactly as a same-calendar "all
|
||||
// events" save would: the moved master carries the source's values,
|
||||
// so the dirty diff against [original] writes only what changed
|
||||
// (including a time or rrule edit made in the same save).
|
||||
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
||||
} catch (t: Throwable) {
|
||||
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||
// source is still intact (it's deleted only past this point).
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
throw t
|
||||
}
|
||||
deleteEvent(eventId)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
MoveMasterProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
MoveMaster(
|
||||
uid = r.getString(MoveMasterProjection.IDX_UID),
|
||||
event = MasterEventSnapshot(
|
||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
||||
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
||||
|
||||
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
||||
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||
arrayOf(fromEventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
||||
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
||||
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
||||
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
||||
}
|
||||
}
|
||||
} ?: Unit
|
||||
|
||||
/**
|
||||
* The event's editable guests as [EventAttendee]s — the rows the move
|
||||
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
||||
* rows (backend-owned, not user-editable) and any without an email are
|
||||
* dropped; the response status resets to "invited" on re-insert.
|
||||
*/
|
||||
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every exception of the series [fromEventId] against the moved series
|
||||
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
||||
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
||||
* exception, a modified one is re-inserted with its fields (then its reminders
|
||||
* reconciled and editable guests copied). No-op for a non-recurring event.
|
||||
*/
|
||||
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
||||
queryExceptionRows(fromEventId).forEach { ex ->
|
||||
if (ex.isCancelled) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
values,
|
||||
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
||||
} else {
|
||||
val uri = resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
buildCopiedExceptionValues(ex).toContentValues(),
|
||||
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
||||
val newExceptionId = ContentUris.parseId(uri)
|
||||
// The provider may clone the parent's reminders onto the new
|
||||
// exception; reconcile to the source exception's exact set so
|
||||
// they neither double nor drop. Same DTSTART → same all-day
|
||||
// encoding, so the stored offsets match directly.
|
||||
reconcileReminders(
|
||||
newExceptionId,
|
||||
queryReminders(ex.exceptionEventId).map { it.minutes },
|
||||
)
|
||||
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
ExceptionProjection.COLUMNS,
|
||||
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(seriesEventId.toString()),
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val r = CursorColumnReader(c)
|
||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||
ExceptionRowSnapshot(
|
||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||
status = status,
|
||||
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
||||
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
||||
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -61,6 +61,20 @@ interface CalendarRepository {
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Move an event (recurring: the whole series, with its exceptions) to
|
||||
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||
* the new event's `Events._ID`. Copy+delete under the hood
|
||||
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||
* place.
|
||||
*/
|
||||
suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
|
||||
@@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||
*/
|
||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
"P${spanMillis / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +191,121 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||
* account, so the moved copy inherits the target calendar's colour instead.
|
||||
*/
|
||||
internal data class MasterEventSnapshot(
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val status: Int?,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values re-creating [snapshot] as a fresh Events row on
|
||||
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||
* caller layers the user's field edits on top with a normal series update.
|
||||
*/
|
||||
internal fun buildMovedMasterValues(
|
||||
snapshot: MasterEventSnapshot,
|
||||
targetCalendarId: Long,
|
||||
uid: String,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
if (snapshot.rrule != null) {
|
||||
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||
} else {
|
||||
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
|
||||
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||
*/
|
||||
internal data class ExceptionRowSnapshot(
|
||||
val exceptionEventId: Long,
|
||||
val originalInstanceMillis: Long,
|
||||
val isCancelled: Boolean,
|
||||
val status: Int?,
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||
*/
|
||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||
buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration(
|
||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||
snapshot.isAllDay,
|
||||
),
|
||||
)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
|
||||
@@ -186,6 +186,86 @@ internal object SearchProjection {
|
||||
const val IDX_RDATE = 11
|
||||
}
|
||||
|
||||
/**
|
||||
* The master/one-off Events row of an event about to be moved to another
|
||||
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||
*/
|
||||
internal object MoveMasterProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_UID = 0
|
||||
const val IDX_TITLE = 1
|
||||
const val IDX_DTSTART = 2
|
||||
const val IDX_DTEND = 3
|
||||
const val IDX_DURATION = 4
|
||||
const val IDX_RRULE = 5
|
||||
const val IDX_RDATE = 6
|
||||
const val IDX_EXDATE = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_AVAILABILITY = 10
|
||||
const val IDX_ACCESS_LEVEL = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_LOCATION = 13
|
||||
const val IDX_DESCRIPTION = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||
*/
|
||||
internal object ExceptionProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||
const val IDX_STATUS = 2
|
||||
const val IDX_TITLE = 3
|
||||
const val IDX_ALL_DAY = 4
|
||||
const val IDX_DTSTART = 5
|
||||
const val IDX_DTEND = 6
|
||||
const val IDX_DURATION = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_AVAILABILITY = 9
|
||||
const val IDX_ACCESS_LEVEL = 10
|
||||
const val IDX_LOCATION = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
val locale = Locale.getDefault()
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The one line of time context in a reminder notification. Pure so it can be
|
||||
* JVM-tested:
|
||||
* JVM-tested.
|
||||
*
|
||||
* - timed, same day: "09:30 – 10:00"
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||
* Timed events that fall on a day other than [today] are prefixed with that
|
||||
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||
* today (issue #46). The prefix prefers natural language and stays short:
|
||||
*
|
||||
* - today: "09:30 – 10:00" (no prefix)
|
||||
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||
* All-day instances already carry an explicit date, so they never gain a
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val prefix = relativeDayPrefix(
|
||||
day = begin.toLocalDate(),
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
)
|
||||
if (prefix == null) range else "$prefix, $range"
|
||||
} else {
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// applies to the time portion too. The explicit dates already say which
|
||||
// day, so no relative prefix is layered on top.
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||
* common case, which needs no prefix). Weekday names are used only within the
|
||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||
* anything past this week falls back to the exact date.
|
||||
*/
|
||||
private fun relativeDayPrefix(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
locale: Locale,
|
||||
dateFormat: DateTimeFormatter,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||
0L -> null
|
||||
1L -> tomorrowLabel
|
||||
-1L -> yesterdayLabel
|
||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||
} else {
|
||||
dateFormat.format(day)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [day] and [today] share the same week. The week boundary honours the
|
||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||
*/
|
||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||
}
|
||||
|
||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||
return minusDays(backtrack.toLong())
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -29,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -195,56 +182,18 @@ private fun CustomReminderEditor(
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Unit toggle first so it stays visible above the keyboard once the
|
||||
// amount field (the bottom row) is focused and scrolled into view.
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = unit == entry,
|
||||
onClick = { onUnitChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||
// all on one row, sitting just above the keyboard.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = unit.ordinal,
|
||||
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
enabled = amount != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@@ -364,41 +313,136 @@ private fun CustomDaysEditor(
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = days != null,
|
||||
onConfirm = { days?.let(onConfirm) },
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||
* is reused for both the rows and the custom preview.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoozeDurationPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: Int,
|
||||
label: @Composable (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val customSelected = selected !in presets
|
||||
val rowCount = presets.size + 1 // + the custom row
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||
}
|
||||
var unit by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
title = label(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(minute)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
CustomSnoozeEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
label = label,
|
||||
onConfirm = { minutes ->
|
||||
onSelect(minutes)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole hours if the delay divides evenly, else minutes. */
|
||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||
|
||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||
|
||||
/**
|
||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final delay in minutes.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomSnoozeEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
label: @Composable (Int) -> String,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
|
||||
@@ -618,15 +618,16 @@ private fun EventEditContent(
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
|
||||
// Calendar card — tap anywhere to pick the target calendar. Editing
|
||||
// keeps the owning calendar (moving events between calendars is a
|
||||
// sync-adapter minefield; every stock calendar app locks it too).
|
||||
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
||||
// different one while editing *moves* the event (copy+delete on save,
|
||||
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
||||
// events stay locked: the contact sync owns their calendar.
|
||||
EditCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
iconTint = accent,
|
||||
onClick = { showCalendarPicker = true }
|
||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
||||
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
|
||||
@@ -439,10 +439,15 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||
// be updated in place) and is inherently whole-series: an occurrence
|
||||
// can't live in a different calendar than its series, so a move skips the
|
||||
// scope dialog even for a recurring event.
|
||||
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||
// Managed events are a yearly series whose editable fields (reminders,
|
||||
// notes, location) live on the series row — never offer the scope dialog,
|
||||
// which would split the series into an exception the sync then reverts.
|
||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
@@ -497,6 +502,17 @@ class EventEditViewModel @Inject constructor(
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else if (form.calendarId != target.original.calendarId) {
|
||||
// Move to the picked calendar (copy+delete), carrying any
|
||||
// field edits made in the same save. The target becomes the
|
||||
// last-used calendar, like a create does.
|
||||
repository.moveEvent(
|
||||
eventId = target.eventId,
|
||||
targetCalendarId = requireNotNull(form.calendarId),
|
||||
original = target.original,
|
||||
updated = form,
|
||||
)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else {
|
||||
when (scope) {
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
|
||||
@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
@@ -1174,10 +1175,9 @@ private fun NotificationsScreen(
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
OptionPicker(
|
||||
SnoozeDurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
predictiveBack = true,
|
||||
options = SNOOZE_PRESETS,
|
||||
presets = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||
|
||||
@@ -226,6 +226,9 @@
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||
<string name="reminder_day_yesterday">Yesterday</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
|
||||
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
)
|
||||
val updated = original.copy(calendarId = 3L)
|
||||
|
||||
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
|
||||
|
||||
assertThat(newId).isEqualTo(77L)
|
||||
assertThat(fake.movedEvents).containsExactly(
|
||||
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("insert moved event into calendar id=3")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
)
|
||||
|
||||
try {
|
||||
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
|
||||
error("Expected WriteFailedException")
|
||||
} catch (expected: WriteFailedException) {
|
||||
assertThat(expected.message).contains("3")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -298,4 +298,114 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||
}
|
||||
|
||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||
|
||||
private fun masterSnapshot(
|
||||
isAllDay: Boolean = false,
|
||||
dtStartMillis: Long = 1_781_164_800_000L,
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
|
||||
duration: String? = null,
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
exdate: String? = null,
|
||||
) = MasterEventSnapshot(
|
||||
title = "Standup",
|
||||
isAllDay = isAllDay,
|
||||
dtStartMillis = dtStartMillis,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
status = CalendarContract.Events.STATUS_CONFIRMED,
|
||||
location = "Room 1",
|
||||
description = "",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
|
||||
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
|
||||
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
|
||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
// Empty description clears explicitly; a raw/keyed colour is never copied
|
||||
// (may be invalid on the target account — the copy inherits its colour).
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
|
||||
// Recurring rows never carry DTEND (the provider's invariant).
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series without a stored duration derives it from DTEND`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
|
||||
|
||||
private fun exceptionSnapshot(
|
||||
isCancelled: Boolean = false,
|
||||
duration: String? = null,
|
||||
// Occurrence starts one hour into the day and runs 30 minutes.
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
|
||||
) = ExceptionRowSnapshot(
|
||||
exceptionEventId = 42L,
|
||||
originalInstanceMillis = 1_781_164_800_000L,
|
||||
isCancelled = isCancelled,
|
||||
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
|
||||
title = "Moved occurrence",
|
||||
isAllDay = false,
|
||||
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
location = "",
|
||||
description = "Notes",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot())
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||
// 30-minute occurrence derived from DTEND, written as DURATION only.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence keeps a stored duration verbatim`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
val insertedForms = mutableListOf<EventForm>()
|
||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||
data class MovedEvent(
|
||||
val eventId: Long,
|
||||
val targetCalendarId: Long,
|
||||
val original: EventForm,
|
||||
val updated: EventForm,
|
||||
)
|
||||
val movedEvents = mutableListOf<MovedEvent>()
|
||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val deletedEventIds = mutableListOf<Long>()
|
||||
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
|
||||
private fun utcMidnight(date: LocalDate): Long =
|
||||
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
|
||||
private fun text(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
isAllDay: Boolean = false,
|
||||
zone: ZoneId = berlin,
|
||||
locale: Locale = Locale.GERMANY,
|
||||
is24Hour: Boolean = true,
|
||||
today: LocalDate? = null,
|
||||
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
|
||||
): String = reminderTimeText(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = isAllDay,
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = "Tomorrow",
|
||||
yesterdayLabel = "Yesterday",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event on one day shows just the time range`() {
|
||||
val text = reminderTimeText(
|
||||
fun `timed event today shows just the time range`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `12-hour preference renders an am-pm time range`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.US,
|
||||
is24Hour = false,
|
||||
)
|
||||
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tomorrow's event is prefixed with the tomorrow label`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday's event is prefixed with the yesterday label`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event later this week is prefixed with the short weekday`() {
|
||||
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("So., 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the week boundary honours the week-start setting`() {
|
||||
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
|
||||
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
|
||||
// and must render as a date — the opposite of the Monday-start case above.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
firstDayOfWeek = DayOfWeek.SUNDAY,
|
||||
)
|
||||
assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event next week falls back to the exact date, not a weekday`() {
|
||||
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event crossing midnight includes both dates`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).contains("11.06.2026")
|
||||
assertThat(text).contains("12.06.2026")
|
||||
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
|
||||
|
||||
@Test
|
||||
fun `all-day single day shows one date, read in UTC`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||
isAllDay = true,
|
||||
// Zone must not matter for all-day events: UTC midnight is
|
||||
// 02:00 in Berlin — naive local reading would shift the day.
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||
isAllDay = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("12.06.2026")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||
}
|
||||
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
|
||||
@Test
|
||||
fun `degenerate all-day range never renders an inverted span`() {
|
||||
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = day,
|
||||
endMillis = day,
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
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.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.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 kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
|
||||
/**
|
||||
* Focuses on the save-time branch that decides between an in-place update and a
|
||||
* calendar *move* (copy+delete). The provider-level move itself is verified
|
||||
* on-device; here the fake records which repository call the ViewModel chose.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventEditViewModelTest {
|
||||
|
||||
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 cal(id: Long): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
)
|
||||
|
||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||
)
|
||||
|
||||
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
|
||||
// last-used calendar) completes under advanceUntilIdle instead of on a real
|
||||
// IO thread the virtual clock can't observe.
|
||||
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val s = settings(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
}
|
||||
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
||||
|
||||
@Test
|
||||
fun `changing the calendar routes the save through a move, not an update`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
nextInsertId = 99L
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
|
||||
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// No AwaitingScope park: a move is inherently whole-series.
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event without moving still asks for the scope`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setTitle("Renamed")
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
|
||||
assertThat(fake.movedEvents).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Submodule floret-kit updated: 78c5fd1fd4...2124227a7f
Reference in New Issue
Block a user