Compare commits

..

1 Commits

Author SHA1 Message Date
c63dfddb88 feat(intent): handle ACTION_EDIT and broaden .ics MIME types
Round out the calendar-intent surface toward AOSP/Etar parity — the app
already handled VIEW (date + event), INSERT, and .ics open/share, but was
missing the edit action and the alternate .ics MIME labels.

- ACTION_EDIT on content://com.android.calendar/events/<id> now opens the
  event in the edit form (previously only VIEW → read-only detail existed).
  An assistant, task app, or widget can hand an event to Calendula to edit.
  A bare EDIT URI with no occurrence extras falls back to the event row's
  own DTSTART/DTEND, mirroring the #48 view-event fallback.
- ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same
  prefilled create form as ACTION_INSERT.
- The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar
  1.0 / .vcs) and application/ics — the alternate labels the same calendar
  data arrives under from some file/mail apps (matches Etar's ImportActivity).

Deliberately excluded: webcal:// / http(s) remote-calendar subscription
(needs INTERNET, which the app doesn't have) and the Google-web-link handler
(Google-specific + network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:22:06 +02:00
39 changed files with 275 additions and 1845 deletions

View File

@@ -3,15 +3,13 @@ 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 }}

View File

@@ -7,69 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a small "No more events today" card appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
### 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.1] — 2026-07-13
### Fixed
- Deleting one occurrence of a repeating event no longer breaks the series.
Choosing "This event" when deleting an occurrence of a recurring event could
wipe out every *other* occurrence while leaving the one you deleted behind as
a stale, still-tappable ghost — and deleting it again brought the series back.
A single-occurrence delete now removes exactly that occurrence and leaves the
rest of the series untouched, and the deleted occurrence disappears from the
grid straight away. This holds on every kind of calendar, including the
on-device ones Calendula keeps for contact birthdays and anniversaries, where
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
Calendula to answer the "new event" hand-off from other apps and widgets; now
it also answers the "open this event" one, so tapping an existing event in a
widget such as Todo Agenda offers Calendula and lands on that event's details.
Thanks to @bushrang3r for the report ([#48]).
- Events created from other apps get your default reminder. An event handed over
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
widget's "+" — opened with no reminder at all, ignoring the default set in
Settings. It now starts with your default reminder, the same as an event you
create in Calendula. An event opened from an `.ics` file is treated differently,
because the file has its own say: Calendula keeps whatever reminders it carries
(including none at all) and asks you once whether to apply your default instead
— it never quietly overrides the file. If you have no default set, it doesn't
ask ([#49]).
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
@@ -950,10 +888,3 @@ 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
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21401
versionName = "2.14.1"
versionCode = 21400
versionName = "2.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -18,8 +18,6 @@ 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
@@ -181,25 +179,6 @@ 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
@@ -906,190 +885,6 @@ 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,
@@ -1184,8 +979,6 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE,
),
null, null, null,
)?.use { c ->
@@ -1196,8 +989,6 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = c.getString(2),
duration = c.getString(3),
allDay = c.getInt(4),
syncId = c.getString(5),
exdate = c.getString(6),
)
} else {
null
@@ -1210,9 +1001,6 @@ class AndroidCalendarDataSource @Inject constructor(
val timezone: String?,
val duration: String?,
val allDay: Int,
/** Null on a local calendar (and before a synced event's first push). */
val syncId: String? = null,
val exdate: String? = null,
) {
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
@@ -1403,38 +1191,15 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
if (row.syncId == null) {
// No _sync_id — a local calendar, or a synced event not pushed yet.
// A cancelled exception can only attach to its parent through
// ORIGINAL_SYNC_ID, so with none the link never forms and the
// provider's expansion of the *parent* collapses, taking every other
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use.
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return
}
// A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
// the full time set (DTSTART + DURATION + zone) so the provider derives a
// single instance rather than cloning the master's RRULE — the same trap
// the edit path documents (Codeberg #16).
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
// the provider only demotes the cloned exception to a single instance —
// clearing the inherited RRULE — when it can derive that instance from
// those columns. A STATUS-only cancel left the RRULE standing and
// cancelled the whole series, wiping every other occurrence (#47), the
// same trap the edit path documents (Codeberg #16).
val row = querySeriesRow(eventId)
val values = buildOccurrenceCancelValues(
originalInstanceMillis = beginMillis,
dtStartMillis = beginMillis,

View File

@@ -61,20 +61,6 @@ 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`.

View File

@@ -157,17 +157,6 @@ 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,

View File

@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@@ -37,18 +36,15 @@ internal fun ColumnReader.toEventDetailCore(
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null — derive the length from DURATION (as SearchMapper and
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
// both times with the per-occurrence values from CalendarContract.Instances;
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
// intent, issue #48) keeps this row's own times, so the length has to be
// right here or the series renders zero-length. A present-but-backwards
// DTEND is malformed, but dropping the row would make the event un-openable
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper).
// DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
// but dropping the row would make the event un-openable — the same trap as
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
// generic error screen with no way to open the event and fix it. Clamp to a
// zero-length event instead (matching SearchMapper's coerceAtLeast).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
begin
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}

View File

@@ -43,19 +43,10 @@ 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 =
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"
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
} else {
"P${spanMillis / 1_000L}S"
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
}
/**
@@ -191,121 +182,6 @@ 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) }
}
/**
* Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
@@ -333,73 +209,6 @@ internal fun buildOccurrenceCancelValues(
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
/**
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
* series by adding it to `EXDATE` — the path for events that have **no
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
*
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
* forms, and the provider's expansion of the *parent* collapses — every other
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
* sync adapter carries it upstream unchanged if the calendar later syncs.
*
* The whole time/recurrence set is rewritten alongside it on purpose. The
* provider does **not** treat an EXDATE-only update as a recurrence change: it
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
* `lastDate` as if the event were a single instance, collapsing the series to its
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
*
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
*/
internal fun buildOccurrenceExdateValues(
existingExdate: String?,
occurrenceMillis: Long,
dtStartMillis: Long,
rrule: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
val existing = existingExdate?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val merged = (existing + stamp).distinct().joinToString(",")
return mapOf(
CalendarContract.Events.EXDATE to merged,
CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.DURATION to duration,
CalendarContract.Events.EVENT_TIMEZONE to timezone,
CalendarContract.Events.ALL_DAY to allDay,
)
}
/**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day.
*/
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
return if (isAllDay) {
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
} else {
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
)
}
}
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the

View File

@@ -84,9 +84,6 @@ internal object EventDetailProjection {
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY,
// Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION,
)
const val IDX_EVENT_ID = 0
@@ -107,7 +104,6 @@ internal object EventDetailProjection {
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
const val IDX_DURATION = 18
}
/**
@@ -190,86 +186,6 @@ 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,

View File

@@ -252,20 +252,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* Whether the agenda (both the in-app screen and the home-screen widget)
* always anchors today at the top with a "nothing left today" placeholder,
* even once today has no remaining events (issue #35). Default ON — makes
* today's events easy to tell apart from a future day's at a glance.
*/
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
}
suspend fun setAgendaShowToday(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
@@ -687,8 +673,6 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")

View File

@@ -17,11 +17,7 @@ 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
@@ -59,22 +55,13 @@ 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 = zone,
locale = locale,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
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)

View File

@@ -1,37 +1,23 @@
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 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)
* - timed, same day: "09:30 10:00"
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
*
* 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
* All-day instances store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/
fun reminderTimeText(
@@ -41,10 +27,6 @@ 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)
@@ -61,70 +43,18 @@ 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()) {
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"
timeFormat.format(begin) + RANGE + timeFormat.format(end)
} 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. The explicit dates already say which
// day, so no relative prefix is layered on top.
// applies to the time portion too.
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
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 = " "

View File

@@ -1,9 +1,10 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
@@ -86,12 +87,10 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
/**
* The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header. Both ends use the same day-month
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
* (inclusive), for a human-readable header:
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
* - everything else → "start end" ("27 Jun 3 Jul 2026")
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
@@ -101,15 +100,11 @@ fun agendaRangeWindowSummary(
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
return when (range) {
AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"
}
AgendaRange.Day -> medium.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}"
}
}

View File

@@ -15,17 +15,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -66,7 +65,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
@@ -85,6 +83,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val zone = TimeZone.currentSystemDefault()
@@ -107,7 +106,6 @@ fun AgendaScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -172,11 +170,9 @@ fun AgendaScreen(
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
@@ -194,7 +190,6 @@ fun AgendaScreen(
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
@@ -218,11 +213,9 @@ fun AgendaScreen(
}
/**
* The agenda's current range, tapped to open the range picker as a session-only
* override. Shares the top-bar view switcher's button shape so the two read as a
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
* switcher's secondary container, so it doesn't compete. Fills with the primary
* container only while an override is active, to make that temporary state clear.
* A compact tonal pill showing the agenda's current range. Tapping it opens the
* range picker as a session-only override. Filled with the primary container
* while an override is active, so the temporary state is obvious.
*/
@Composable
private fun AgendaRangePill(
@@ -231,34 +224,43 @@ private fun AgendaRangePill(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FilledTonalButton(
onClick = onClick,
shape = MaterialTheme.shapes.large,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = if (isOverride) {
val container = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
contentColor = if (isOverride) {
}
val content = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
modifier = modifier,
}
Surface(
color = container,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
* lives on the selector button beside it, so it isn't repeated here.
* A header naming the concrete window currently shown, e.g. "Showing all events
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026".
*/
@Composable
private fun AgendaRangeBanner(
@@ -275,10 +277,8 @@ private fun AgendaRangeBanner(
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Just the concrete dates — the range's name ("Next 30 days") already
// sits on the selector button to the right, so repeating it here is noise.
Text(
text = window,
text = "${agendaRangeLabel(range)}, $window",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -289,7 +289,6 @@ private fun AgendaRangeBanner(
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -305,7 +304,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
val days = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -313,14 +312,6 @@ private fun AgendaContent(
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
@@ -358,12 +349,6 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
}
} else {
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
@@ -376,7 +361,6 @@ private fun AgendaList(
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -407,44 +391,6 @@ private fun AgendaDayHeader(
}
}
/**
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
* visible slot when nothing is left rather than a bare header.
*/
@Composable
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
Card(
// Match a single event row's resting corner radius (floret groupedShape).
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.agenda_no_more_today),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun AgendaEventRow(
event: EventInstance,
@@ -574,7 +520,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}

View File

@@ -41,24 +41,6 @@ fun groupAgendaDays(
)
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear).

View File

@@ -66,19 +66,6 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW,
)
/**
* Whether to keep today anchored at the top with a "nothing left today"
* placeholder even once it has no remaining events (#35). A display concern
* applied in the composition (after past-event filtering), so it rides
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate

View File

@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
@@ -19,15 +18,3 @@ fun currentLocale(): Locale {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}
/**
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
* (via Android's best-pattern matching), so dates read naturally per locale
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
*/
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
locale,
)

View File

@@ -1,14 +1,26 @@
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
@@ -17,12 +29,13 @@ 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.CustomAmountEditor
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
@@ -182,18 +195,56 @@ private fun CustomReminderEditor(
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
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),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
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) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
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. */
@@ -313,134 +364,39 @@ private fun CustomDaysEditor(
) {
val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
placeholder = "30",
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = days != null,
onConfirm = { days?.let(onConfirm) },
)
}
/**
* 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(),
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),
) {
CustomSnoozeEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
label = label,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
},
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30",
)
Spacer(Modifier.width(16.dp))
Text(
text = 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),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { days?.let(onConfirm) },
enabled = days != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
/** 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 1999
* 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). */

View File

@@ -672,16 +672,15 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
// 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.
// 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).
EditCard(
icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent,
onClick = { showCalendarPicker = true }
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
) {
Text(
text = selectedCalendar?.displayName

View File

@@ -548,15 +548,10 @@ 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 && !movingCalendar) {
if (target != null && target.original.rrule != null && !current.isManaged) {
_saveState.value = SaveUiState.AwaitingScope
return
}
@@ -611,17 +606,6 @@ 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 ->

View File

@@ -380,10 +380,7 @@ private fun MonthGrid(
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
state.weeks.forEach { week ->

View File

@@ -133,7 +133,6 @@ 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
@@ -631,18 +630,6 @@ private fun AppearanceScreen(
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
@@ -1187,9 +1174,10 @@ private fun NotificationsScreen(
}
if (showSnooze) {
SnoozeDurationPicker(
OptionPicker(
title = stringResource(R.string.settings_snooze_duration),
presets = SNOOZE_PRESETS,
predictiveBack = true,
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },

View File

@@ -39,8 +39,6 @@ data class SettingsUiState(
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */

View File

@@ -37,7 +37,6 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
@@ -117,20 +116,14 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
::Triple,
),
) { view, screenRange, widgetRange, timeFormat, toggles ->
// Two grid-display toggles folded into one flow so they fit this
// group — the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = toggles.first,
showWeekNumbers = toggles.second,
agendaShowToday = toggles.third,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
)
},
combine(
@@ -154,7 +147,6 @@ class SettingsViewModel @Inject constructor(
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -228,7 +220,6 @@ class SettingsViewModel @Inject constructor(
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
)
private data class MiscSettings(
@@ -407,22 +398,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}

View File

@@ -114,10 +114,6 @@ import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
* (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -452,10 +448,9 @@ private fun WeekDayHeader(
.padding(top = 4.dp, bottom = 8.dp),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
// badge lines up vertically with the date numbers.
Column(
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
modifier = Modifier.width(GUTTER_WIDTH),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
@@ -626,12 +621,10 @@ private fun Timeline(
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
// Hour gutter (scrolls in sync with the day columns)
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {

View File

@@ -66,12 +66,6 @@ sealed interface AgendaWidgetData {
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -123,7 +117,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
@@ -139,7 +132,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
now = Clock.System.now(),
)
}

View File

@@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
@@ -49,13 +48,11 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -68,6 +65,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
@@ -93,14 +91,6 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
@@ -128,8 +118,6 @@ class RefreshAgendaAction : ActionCallback {
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow
}
@Composable
@@ -151,17 +139,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
@@ -172,28 +156,20 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
} else {
days
}
},
today = data.today,
enabled = showToday,
)
}
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(it)) }
}
}
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
@@ -286,28 +262,6 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
)
}
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable
private fun PlaceholderRow(date: LocalDate) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current
@@ -383,9 +337,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
}
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout.
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
val formatted = "$weekday, ${date.day} $monthName"
return if (relative != null) "$relative · $formatted" else formatted
}

View File

@@ -310,7 +310,7 @@
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string>
@@ -474,20 +474,4 @@
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_week_numbers">Kalenderwochen</string>
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
<string name="calendars_export_title">Kalender exportieren</string>
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
<string name="calendars_export_action">Exportieren</string>
<string name="calendars_restore_header">Wiederherstellen</string>
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
<string name="import_done_added_label">Hinzugefügt</string>
<string name="import_done_skipped_label">Duplikate</string>
<string name="import_button">Importieren</string>
<plurals name="import_title_count">
<item quantity="one">%d Ereignis wird importiert</item>
<item quantity="other">%d Ereignisse werden importiert</item>
</plurals>
</resources>

View File

@@ -16,17 +16,17 @@
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
<string name="permission_retry_button">Riprova</string>
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
@@ -38,7 +38,7 @@
<string name="month_action_settings">Impostazioni</string>
<string name="month_a11y_today_prefix">Oggi</string>
<string name="week_today_action">Questa settimana</string>
<string name="week_number_label">Sett.</string>
<string name="week_number_label">Wk</string>
<string name="day_today_action">Oggi</string>
<string name="event_detail_back">Indietro</string>
<string name="event_detail_edit">Modifica</string>
@@ -73,14 +73,14 @@
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
<string name="event_edit_attendees">Partecipanti</string>
<string name="event_edit_add_guest">Aggiungi partecipante</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante tramite indirizzo email…</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
<string name="event_edit_attendee_required">Obbligatorio</string>
<string name="event_edit_attendee_optional">Facoltativo</string>
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
<string name="event_edit_reminder_custom">Personalizza</string>
<string name="reminder_unit_minutes">minuti</string>
<string name="reminder_unit_hours">ore</string>
@@ -89,7 +89,7 @@
<string name="event_edit_availability">Disponibilità</string>
<string name="event_edit_visibility">Visibilità</string>
<string name="event_edit_color">Colore</string>
<string name="event_edit_color_default">Colore del calendario</string>
<string name="event_edit_color_default">Colore calendario</string>
<string name="event_edit_color_custom">Colore personalizzato</string>
<string name="event_edit_color_reset">Reset</string>
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
@@ -99,13 +99,13 @@
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
<string name="event_edit_conflict_discard">Annulla le modifiche</string>
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
<string name="event_edit_gone_title">Evento eliminato</string>
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
<string name="event_edit_recurrence_none">Non si ripete</string>
<string name="event_edit_recurrence_custom">Personalizza</string>
<string name="event_edit_recurrence_every">Ogni</string>
<string name="event_edit_recurrence_every">La serie</string>
<string name="recurrence_unit_days">giorni</string>
<string name="recurrence_unit_weeks">settimane</string>
<string name="recurrence_unit_months">mesi</string>
@@ -113,7 +113,7 @@
<string name="event_edit_recurrence_ends">Fine</string>
<string name="event_edit_recurrence_end_never">Mai</string>
<string name="event_edit_recurrence_end_until">Fino alla data</string>
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
<string name="event_edit_recurrence_times">occorrenze</string>
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
<string name="event_availability_busy">Occupato</string>
@@ -125,7 +125,7 @@
<string name="event_detail_location">Luogo</string>
<string name="event_detail_description">Descrizione</string>
<string name="event_detail_attendees">Partecipanti</string>
<string name="recurrence_daily">Ogni giorno</string>
<string name="recurrence_daily">Ogni giorni</string>
<string name="recurrence_weekly">Ogni settimana</string>
<string name="recurrence_monthly">Ogni mese</string>
<string name="recurrence_yearly">Ogni anno</string>
@@ -204,7 +204,7 @@
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
<string name="reminder_onboarding_skip_button">Non ora</string>
<string name="reminder_action_snooze">Posticipa</string>
<string name="reminder_action_snooze">Posponi</string>
<string name="reminder_action_dismiss">Ignora</string>
<string name="view_month">Mese</string>
<string name="view_week">Settimana</string>
@@ -418,16 +418,16 @@
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
<string name="settings_font_headings">Carattere dell\'intestazione</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_custom_selected">Carattere personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
@@ -463,7 +463,4 @@
<string name="special_dates_calendar_custom">Date speciali</string>
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -236,9 +236,6 @@
<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>
@@ -255,7 +252,6 @@
<string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_no_more_today">No more events today</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -322,8 +318,6 @@
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
<string name="agenda_range_day">Today</string>

View File

@@ -312,46 +312,6 @@ 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 {

View File

@@ -32,7 +32,6 @@ class EventDetailMapperTest {
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
duration: String? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
@@ -52,7 +51,6 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
EventDetailProjection.IDX_DURATION to duration,
)
private fun attendeeReader(
@@ -136,32 +134,12 @@ class EventDetailMapperTest {
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
// A yearly birthday/anniversary anchored before the epoch has a
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
// no DTEND (they use DURATION), so the length comes from DURATION.
// no DTEND (they use DURATION), so it stays end == begin.
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
}
@Test
fun `absent dtend takes its length from DURATION (issue #48)`() {
// A recurring series row has no DTEND. Opened without a named
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
// own times are what render, so DURATION has to supply the length or
// the event shows as zero-length (10:0010:00).
val begin = 1_000_000_000L
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
}
@Test
fun `absent dtend and absent DURATION stays zero-length`() {
val begin = 1_000_000_000L
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
}
@Test

View File

@@ -260,77 +260,6 @@ class EventWriteMapperTest {
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
}
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
@Test
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
// 2026-07-15T08:00:00Z.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
// The whole time/recurrence set rides along: an EXDATE-only update is not
// treated as a recurrence change, so the provider would leave the expanded
// instances (and the occurrence) in place. DTSTART alone is worse — it
// makes the provider recompute lastDate as a single instance and collapse
// the series to its first occurrence.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `exdate drop appends to an existing exdate list`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260714T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("20260714T080000Z,20260715T080000Z")
}
@Test
fun `exdate drop folds away a repeated occurrence`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
// --- per-event colour ---
@Test
@@ -409,114 +338,4 @@ 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")
}
}

View File

@@ -34,13 +34,6 @@ 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>()
@@ -123,19 +116,6 @@ 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,

View File

@@ -2,7 +2,6 @@ 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
@@ -19,110 +18,41 @@ 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 today shows just the time range`() {
val text = text(
fun `timed event on one day shows just the time range`() {
val text = reminderTimeText(
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 = text(
val text = reminderTimeText(
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 = text(
val text = reminderTimeText(
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")
@@ -131,35 +61,28 @@ class ReminderTimeTextTest {
@Test
fun `all-day single day shows one date, read in UTC`() {
val text = text(
val text = reminderTimeText(
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.
today = LocalDate.of(2026, 6, 11),
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
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 = text(
val text = reminderTimeText(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("11.06.2026 12.06.2026")
}
@@ -167,11 +90,13 @@ class ReminderTimeTextTest {
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = text(
val text = reminderTimeText(
beginMillis = day,
endMillis = day,
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("11.06.2026")
}

View File

@@ -1,39 +0,0 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Covers the agenda widget's "always show today" anchor logic (#35). */
class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
}
@Test
fun `enabled prepends an empty today when it is missing`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result).hasSize(2)
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
assertThat(result[1]).isEqualTo(days.first())
}
@Test
fun `enabled anchors today into an otherwise empty list`() {
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
}
@Test
fun `enabled is a no-op when today already has its own day`() {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
}

View File

@@ -1,158 +0,0 @@
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()
}
}

View File

@@ -1,34 +0,0 @@
### Fixed
- Deleting one occurrence of a repeating event no longer breaks the series.
Choosing "This event" when deleting an occurrence of a recurring event could
wipe out every *other* occurrence while leaving the one you deleted behind as
a stale, still-tappable ghost — and deleting it again brought the series back.
A single-occurrence delete now removes exactly that occurrence and leaves the
rest of the series untouched, and the deleted occurrence disappears from the
grid straight away. This holds on every kind of calendar, including the
on-device ones Calendula keeps for contact birthdays and anniversaries, where
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
Calendula to answer the "new event" hand-off from other apps and widgets; now
it also answers the "open this event" one, so tapping an existing event in a
widget such as Todo Agenda offers Calendula and lands on that event's details.
Thanks to @bushrang3r for the report ([#48]).
- Events created from other apps get your default reminder. An event handed over
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
widget's "+" — opened with no reminder at all, ignoring the default set in
Settings. It now starts with your default reminder, the same as an event you
create in Calendula. An event opened from an `.ics` file is treated differently,
because the file has its own say: Calendula keeps whatever reminders it carries
(including none at all) and asks you once whether to apply your default instead
— it never quietly overrides the file. If you have no default set, it doesn't
ask ([#49]).
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
duplicates or near-identical shades. The picker now shows only visually
distinct colours, arranged as a rainbow; near-duplicate shades and the
washed-out neutrals are folded away so no two swatches look alike. Picked
colours still sync exactly as before, and calendars with hand-picked
palettes (like Google's) are unaffected. Thanks to @ptab for the report
([#22]).