feat(edit): event editing — shared form, scoped recurring writes, recurrence picker (v1.3)
The create form (v1.2) now edits: a pencil on the detail screen (writable calendars only, contextual WRITE upgrade like delete) opens it prefilled via EventDetail.toEditForm; populated sections always show, the calendar is fixed, and a dirty-check writes only changed columns (pristine saves are no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope and asks how far the change reaches (Google model): "only this event" = modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as explicit NULLs since the provider clones the parent row), "this and all following" = series split (insert new event first, then truncate), "all events" = series-row update with the time delta applied to the series DTSTART. A changed rule drops the exception option. Delete gained the same middle scope. Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT + weekly BYDAY with locale-ordered weekday toggles) behind a picker on create and edit; unrepresentable rules render humanized (shared ui/common RecurrenceText) and survive verbatim. UNTIL validation flags rules ending before the event starts. Provider lessons baked in (verified on-device via adb probes): instance caches regenerate only from an update's own values, so truncation sends the full time-column set (truncateSeries) — RRULE-only updates left a stale duplicate occurrence on the split day; UNTIL is written as the local end of day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones can't leak an extra day. Reminder edits reconcile against actual provider rows, keeping untouched rows' methods. Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception mapper paths, repository pass-throughs, prefill + populatedFields, raw-title mapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,9 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -37,6 +39,44 @@ interface CalendarDataSource {
|
||||
/** Insert a new event; returns the new `Events._ID`. */
|
||||
fun insertEvent(form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Update an existing event (for recurring events: the whole series) to
|
||||
* match [updated]. [original] is the form as it was prefilled from the
|
||||
* event, so only fields the user actually changed are written and the
|
||||
* reminder rows can be diffed instead of wiped.
|
||||
*/
|
||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`.
|
||||
*/
|
||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||
* by splitting the series: the existing RRULE ends just before the
|
||||
* occurrence and a new event with [updated]'s values (and rule) starts
|
||||
* there; returns the new event's `Events._ID`. From the first occurrence
|
||||
* this is a plain series update. A carried-over COUNT restarts counting
|
||||
* in the new series (we don't recompute the remaining occurrences).
|
||||
*/
|
||||
fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Delete a recurring event from the occurrence at [beginMillis] onwards
|
||||
* by ending the series RRULE just before it. Deleting from the first
|
||||
* occurrence removes the whole event.
|
||||
*/
|
||||
fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||
|
||||
/** Delete the whole event (for recurring events: the entire series). */
|
||||
fun deleteEvent(eventId: Long)
|
||||
|
||||
@@ -101,7 +141,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||
// (and no DTEND), one-off rows carry DTEND.
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
@@ -128,6 +175,186 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||
val values = buildEventUpdateValues(
|
||||
original = original,
|
||||
updated = updated,
|
||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||
}
|
||||
// Untouched reminder sets are left alone so unrelated edits can't
|
||||
// disturb provider rows the form never knew about.
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
reconcileReminders(eventId, updated.reminders)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||
// The provider clones the series row and applies these values on top.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
originalInstanceMillis = beginMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
val uri = resolver.insert(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
|
||||
values.toContentValues(),
|
||||
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||
val exceptionId = ContentUris.parseId(uri)
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, form.reminders)
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
override fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on (or with no rule to split) this is
|
||||
// just a series update.
|
||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||
updateEvent(eventId, original, updated)
|
||||
return eventId
|
||||
}
|
||||
// Insert the new series first: if it fails, the original is untouched.
|
||||
val newEventId = insertEvent(updated)
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on = the whole series; also the fallback
|
||||
// when there is no RRULE to truncate.
|
||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||
deleteEvent(eventId)
|
||||
return
|
||||
}
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* End [row]'s series just before the occurrence at [beginMillis]. The
|
||||
* provider regenerates an event's cached instances only from the values
|
||||
* carried by the update itself — an RRULE-only update leaves the old
|
||||
* instances standing (observed on-device: the truncated occurrence kept
|
||||
* showing) — so the entire time-related set travels together, with only
|
||||
* the RRULE actually changing.
|
||||
*/
|
||||
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
put(
|
||||
CalendarContract.Events.RRULE,
|
||||
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||
)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values,
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
}
|
||||
|
||||
/** The series anchor: every time-related column of the Events row. */
|
||||
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
arrayOf(
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
SeriesRow(
|
||||
dtStartMillis = c.getLong(0),
|
||||
rrule = c.getString(1),
|
||||
timezone = c.getString(2),
|
||||
duration = c.getString(3),
|
||||
allDay = c.getInt(4),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: throw WriteFailedException("read series row of event id=$eventId")
|
||||
|
||||
private data class SeriesRow(
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
val timezone: String?,
|
||||
val duration: String?,
|
||||
val allDay: Int,
|
||||
) {
|
||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
beginMillis = beginMillis,
|
||||
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
||||
*/
|
||||
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||
val target = targetMinutes.toSet()
|
||||
val existing = queryReminders(eventId).map { it.minutes }.toSet()
|
||||
(existing - target).forEach { minutes ->
|
||||
resolver.delete(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
|
||||
CalendarContract.Reminders.MINUTES + " = ?",
|
||||
arrayOf(eventId.toString(), minutes.toString()),
|
||||
)
|
||||
}
|
||||
(target - existing).forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
when (value) {
|
||||
null -> cv.putNull(column)
|
||||
is String -> cv.put(column, value)
|
||||
is Long -> cv.put(column, value)
|
||||
is Int -> cv.put(column, value)
|
||||
else -> error("Unsupported value for $column: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
val deleted = resolver.delete(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
|
||||
@@ -15,11 +15,37 @@ interface CalendarRepository {
|
||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||
suspend fun createEvent(form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Update an event (recurring: the whole series) from a validated form.
|
||||
* [original] is the prefilled form, used to write only what changed.
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
*/
|
||||
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
* returns the new event's `Events._ID`.
|
||||
*/
|
||||
suspend fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/** Delete the whole event (for recurring events: the entire series). */
|
||||
suspend fun deleteEvent(eventId: Long)
|
||||
|
||||
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
||||
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||
|
||||
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
|
||||
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||
}
|
||||
|
||||
class NoSuchEventException(eventId: Long) :
|
||||
|
||||
@@ -74,13 +74,45 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.insertEvent(form)
|
||||
}
|
||||
|
||||
override suspend fun updateEvent(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = withContext(io) {
|
||||
dataSource.updateEvent(eventId, original, updated)
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
form: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
||||
}
|
||||
|
||||
override suspend fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
||||
}
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
override suspend fun deleteEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
) = withContext(io) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
@@ -42,8 +42,9 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
rawEnd
|
||||
}
|
||||
|
||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
// localized placeholder, and the edit form must prefill the true value.
|
||||
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@@ -37,6 +38,118 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty-checked column values for updating an existing Events row: only what
|
||||
* the user actually changed is written, so untouched fields can't stomp
|
||||
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
|
||||
* null value means "set the column to NULL". An empty map means nothing on
|
||||
* the row changed.
|
||||
*
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||
* past occurrences intact when someone edits a later occurrence's time.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
put(CalendarContract.Events.TITLE, updated.title.trim())
|
||||
}
|
||||
if (updated.location.trim() != original.location.trim()) {
|
||||
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
|
||||
}
|
||||
if (updated.description.trim() != original.description.trim()) {
|
||||
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
|
||||
}
|
||||
if (updated.availability != original.availability) {
|
||||
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
|
||||
}
|
||||
if (updated.accessLevel != original.accessLevel) {
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||
}
|
||||
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
|
||||
if (updated.rrule == null) {
|
||||
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
} else {
|
||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a modified-occurrence exception row ("edit only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
originalInstanceMillis: Long,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val times = form.toWriteTimes(zone)
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC millis of the last second of the local day *before* the occurrence at
|
||||
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
|
||||
* The provider's recurrence engine applies UNTIL coarsely (observed on a
|
||||
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
|
||||
* series must end on the previous day, not one second before the occurrence.
|
||||
* With no sub-daily frequencies that is semantically the same cut.
|
||||
*/
|
||||
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
|
||||
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
|
||||
|
||||
private const val MILLIS_PER_DAY = 86_400_000L
|
||||
|
||||
internal fun Availability.toProviderValue(): Int = when (this) {
|
||||
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
||||
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
@@ -19,6 +24,12 @@ data class EventForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val availability: Availability = Availability.Busy,
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/**
|
||||
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||
* means a one-off event. May hold rules the simple picker can't express —
|
||||
* those are kept verbatim until the user picks something else.
|
||||
*/
|
||||
val rrule: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -29,6 +40,7 @@ enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
Visibility,
|
||||
}
|
||||
@@ -37,6 +49,8 @@ enum class EventFormProblem {
|
||||
/** No target calendar — none picked and no writable calendar exists. */
|
||||
NoCalendar,
|
||||
EndBeforeStart,
|
||||
/** The recurrence's UNTIL date lies before the event's first day. */
|
||||
RecurrenceEndsBeforeStart,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,8 +58,64 @@ enum class EventFormProblem {
|
||||
* allowed (display falls back to "(No title)", matching the provider), and a
|
||||
* zero-length timed event is allowed (spec §8: instant events exist).
|
||||
*/
|
||||
/**
|
||||
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
|
||||
* start — the data layer later turns a time edit into a delta on the series.
|
||||
*
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
title = instance.title,
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
* data they carry would be invisible (though still preserved).
|
||||
*/
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||
// An UNTIL before the first day would make the provider generate zero
|
||||
// occurrences — the event would silently vanish from every view.
|
||||
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
|
||||
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
|
||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,16 @@ enum class AccessLevel {
|
||||
Confidential,
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a write to a recurring event reaches. Non-recurring events always
|
||||
* use [AllEvents] (there is only one).
|
||||
*/
|
||||
enum class RecurringWriteScope {
|
||||
ThisEvent,
|
||||
ThisAndFollowing,
|
||||
AllEvents,
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The recurrence shapes the simple picker can express (v1.3): a frequency,
|
||||
* an interval, weekly weekday picks, and an optional end. Anything beyond
|
||||
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
|
||||
* RRULE string the picker shows as "custom" and leaves untouched unless the
|
||||
* user replaces it.
|
||||
*/
|
||||
data class SimpleRecurrence(
|
||||
val freq: RecurrenceFreq,
|
||||
val interval: Int = 1,
|
||||
val end: RecurrenceEnd = RecurrenceEnd.Never,
|
||||
/**
|
||||
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
|
||||
* no BYDAY part — the provider derives the day from DTSTART.
|
||||
*/
|
||||
val byDays: Set<DayOfWeek> = emptySet(),
|
||||
)
|
||||
|
||||
enum class RecurrenceFreq {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
sealed interface RecurrenceEnd {
|
||||
data object Never : RecurrenceEnd
|
||||
|
||||
/** Last day on which an occurrence may fall (inclusive). */
|
||||
data class Until(val date: LocalDate) : RecurrenceEnd
|
||||
|
||||
/** Total number of occurrences, counting the first. */
|
||||
data class Count(val times: Int) : RecurrenceEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an RRULE into the picker's simple shape, or null when the rule uses
|
||||
* parts the picker can't represent (so the UI preserves the original string).
|
||||
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
|
||||
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
|
||||
* [toRRule].
|
||||
*/
|
||||
fun parseSimpleRecurrence(
|
||||
rrule: String,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): SimpleRecurrence? {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';')
|
||||
.filter { it.isNotBlank() }
|
||||
.associate { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}
|
||||
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||
return null
|
||||
}
|
||||
|
||||
val freq = when (parts["FREQ"]?.uppercase()) {
|
||||
"DAILY" -> RecurrenceFreq.Daily
|
||||
"WEEKLY" -> RecurrenceFreq.Weekly
|
||||
"MONTHLY" -> RecurrenceFreq.Monthly
|
||||
"YEARLY" -> RecurrenceFreq.Yearly
|
||||
else -> return null
|
||||
}
|
||||
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||
|
||||
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
|
||||
val byDays = parts["BYDAY"]?.let { raw ->
|
||||
if (freq != RecurrenceFreq.Weekly) return null
|
||||
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
|
||||
} ?: emptySet()
|
||||
|
||||
val until = parts["UNTIL"]
|
||||
val count = parts["COUNT"]
|
||||
if (until != null && count != null) return null
|
||||
val end = when {
|
||||
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
|
||||
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
|
||||
else -> RecurrenceEnd.Never
|
||||
}
|
||||
return SimpleRecurrence(freq, interval, end, byDays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
|
||||
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
|
||||
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
|
||||
* engine has been observed applying UNTIL coarsely after converting it into
|
||||
* the event's timezone, so a plain `T235959Z` can leak one extra day for
|
||||
* zones ahead of UTC.
|
||||
*/
|
||||
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
|
||||
append("FREQ=")
|
||||
append(
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> "DAILY"
|
||||
RecurrenceFreq.Weekly -> "WEEKLY"
|
||||
RecurrenceFreq.Monthly -> "MONTHLY"
|
||||
RecurrenceFreq.Yearly -> "YEARLY"
|
||||
},
|
||||
)
|
||||
if (interval > 1) append(";INTERVAL=$interval")
|
||||
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
|
||||
append(";BYDAY=")
|
||||
append(
|
||||
byDays.sortedBy { it.isoDayNumber }
|
||||
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
|
||||
)
|
||||
}
|
||||
when (val e = end) {
|
||||
RecurrenceEnd.Never -> Unit
|
||||
is RecurrenceEnd.Until -> {
|
||||
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
||||
.toInstant(zone)
|
||||
.toLocalDateTime(TimeZone.UTC)
|
||||
append(
|
||||
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.month.number, utc.day,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
),
|
||||
)
|
||||
}
|
||||
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
DayOfWeek.THURSDAY to "TH",
|
||||
DayOfWeek.FRIDAY to "FR",
|
||||
DayOfWeek.SATURDAY to "SA",
|
||||
DayOfWeek.SUNDAY to "SU",
|
||||
)
|
||||
|
||||
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
|
||||
private fun rruleDay(token: String): DayOfWeek? =
|
||||
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
|
||||
|
||||
/**
|
||||
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
|
||||
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
|
||||
* Used for "delete this and all following occurrences" — the caller passes a
|
||||
* moment just before the first occurrence to remove.
|
||||
*/
|
||||
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||
val kept = rrule.removePrefix("RRULE:").split(';')
|
||||
.filter { it.isNotBlank() }
|
||||
.filterNot { part ->
|
||||
val key = part.substringBefore('=').trim().uppercase()
|
||||
key == "UNTIL" || key == "COUNT"
|
||||
}
|
||||
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
||||
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
until.year, until.month.number, until.day,
|
||||
until.hour, until.minute, until.second,
|
||||
)
|
||||
return (kept + untilPart).joinToString(";")
|
||||
}
|
||||
|
||||
/**
|
||||
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
|
||||
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||
*/
|
||||
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
||||
val date = LocalDate(
|
||||
raw.substring(0, 4).toInt(),
|
||||
raw.substring(4, 6).toInt(),
|
||||
raw.substring(6, 8).toInt(),
|
||||
)
|
||||
if (raw.length >= 15 && raw[8] == 'T') {
|
||||
val time = LocalTime(
|
||||
raw.substring(9, 11).toInt(),
|
||||
raw.substring(11, 13).toInt(),
|
||||
raw.substring(13, 15).toInt(),
|
||||
)
|
||||
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
||||
} else {
|
||||
date
|
||||
}
|
||||
}.getOrNull()
|
||||
@@ -76,6 +76,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
createDateIso = date.toString()
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
// recurring events the form itself asks for the write scope at save
|
||||
// time. A saved edit closes the detail screen too: the occurrence the
|
||||
// user tapped may not exist anymore (time moved, recurrence changed), so
|
||||
// falling back to the auto-refreshing calendar is the only honest
|
||||
// destination.
|
||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
@@ -117,6 +126,10 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
onEdit = {
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +144,26 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
editKey = key,
|
||||
onClose = { editKey = null },
|
||||
onSaved = {
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.icu.text.ListFormatter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
|
||||
* form's repeat card.
|
||||
*/
|
||||
@Composable
|
||||
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.icu.text.ListFormatter
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -35,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
@@ -74,7 +74,6 @@ import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -90,13 +89,14 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.DayOfWeek
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
@@ -109,7 +109,9 @@ import kotlin.time.Instant
|
||||
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||
* be deleted from here (v1.1); edit follows in v1.3.
|
||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||
* event form for this occurrence — for recurring events the form asks how
|
||||
* far the change reaches when saving.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -118,6 +120,7 @@ fun EventDetailScreen(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -133,20 +136,35 @@ fun EventDetailScreen(
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the confirm dialog.
|
||||
// upgrade in place. Granting continues straight into the tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) showDeleteDialog = true
|
||||
if (granted) {
|
||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||
}
|
||||
pendingEdit = false
|
||||
}
|
||||
val onDeleteClick = {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
}
|
||||
val onDeleteClick = {
|
||||
if (hasWritePermission()) {
|
||||
showDeleteDialog = true
|
||||
} else {
|
||||
pendingEdit = false
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
val onEditClick = {
|
||||
if (hasWritePermission()) {
|
||||
onEdit()
|
||||
} else {
|
||||
pendingEdit = true
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
@@ -189,6 +207,15 @@ fun EventDetailScreen(
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
val s = state
|
||||
if (s is EventDetailUiState.Success && s.canModify) {
|
||||
IconButton(
|
||||
onClick = onEditClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
@@ -223,9 +250,9 @@ fun EventDetailScreen(
|
||||
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||
DeleteEventDialog(
|
||||
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||
onConfirm = { wholeSeries ->
|
||||
onConfirm = { scope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.delete(wholeSeries)
|
||||
viewModel.delete(scope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
@@ -234,15 +261,16 @@ fun EventDetailScreen(
|
||||
|
||||
/**
|
||||
* Delete confirmation. Recurring events choose between cancelling just the
|
||||
* tapped occurrence (default) and removing the whole series.
|
||||
* tapped occurrence (default), truncating the series from it onwards, and
|
||||
* removing the whole series.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteEventDialog(
|
||||
isRecurring: Boolean,
|
||||
onConfirm: (wholeSeries: Boolean) -> Unit,
|
||||
onConfirm: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var wholeSeries by rememberSaveable { mutableStateOf(false) }
|
||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
@@ -258,13 +286,18 @@ private fun DeleteEventDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { wholeSeries = false },
|
||||
selected = !wholeSeries,
|
||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||
selected = scope == RecurringWriteScope.ThisEvent,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { wholeSeries = true },
|
||||
selected = wholeSeries,
|
||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||
selected = scope == RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -272,7 +305,9 @@ private fun DeleteEventDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
|
||||
TextButton(
|
||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
@@ -707,116 +742,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.).
|
||||
*/
|
||||
@Composable
|
||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an event's time into a primary line (date, or "All day") and an
|
||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -78,20 +79,23 @@ class EventDetailViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the open event. [wholeSeries] is meaningful only for recurring
|
||||
* events: false cancels just the tapped occurrence. Result lands in
|
||||
* [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||
* Delete the open event. [scope] is meaningful only for recurring events
|
||||
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
|
||||
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||
*/
|
||||
fun delete(wholeSeries: Boolean) {
|
||||
fun delete(scope: RecurringWriteScope) {
|
||||
val target = _target.value ?: return
|
||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
if (wholeSeries) {
|
||||
repository.deleteEvent(target.eventId)
|
||||
} else {
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
when (scope) {
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.deleteEvent(target.eventId)
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||
}
|
||||
DeleteUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -88,6 +90,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -101,49 +104,82 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toKotlinDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalTime
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Full-screen event form (v1.2: create only). Opens prefilled from the FAB's
|
||||
* anchor date; Save validates, writes via the repository, and closes. The
|
||||
* calendar picker offers only writable calendars.
|
||||
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
|
||||
* (v1.3, [editKey] = eventId + the tapped occurrence's begin/end millis).
|
||||
* Save validates and writes via the repository — a dirty recurring event
|
||||
* first asks how far the change reaches (this occurrence / this and
|
||||
* following / whole series) — then closes. The calendar picker offers only
|
||||
* writable calendars; when editing, the calendar is fixed.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventEditScreen(
|
||||
initialDateIso: String?,
|
||||
onClose: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
editKey: LongArray? = null,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso) {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date)
|
||||
LaunchedEffect(initialDateIso, editKey) {
|
||||
if (editKey != null) {
|
||||
viewModel.openForEdit(
|
||||
eventId = editKey[0],
|
||||
beginMillis = editKey[1],
|
||||
endMillis = editKey[2],
|
||||
)
|
||||
} else {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date)
|
||||
}
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||
|
||||
// The form is intentionally forgotten on every close (cancel or save) so
|
||||
// the next FAB tap starts clean; it survives rotation because openNew
|
||||
// no-ops while a form is set.
|
||||
// the next open starts clean; it survives rotation because openNew /
|
||||
// openForEdit no-op while a form is set.
|
||||
val close = {
|
||||
viewModel.reset()
|
||||
onClose()
|
||||
}
|
||||
BackHandler(onBack = close)
|
||||
|
||||
// The event vanished between the detail screen and the edit tap — fall
|
||||
// back to the detail screen, which shows its own failure state.
|
||||
LaunchedEffect(loadFailed) {
|
||||
if (loadFailed) close()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -169,7 +205,10 @@ fun EventEditScreen(
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
LaunchedEffect(state?.saveState) {
|
||||
when (state?.saveState) {
|
||||
SaveUiState.Saved -> close()
|
||||
SaveUiState.Saved -> {
|
||||
viewModel.reset()
|
||||
onSaved()
|
||||
}
|
||||
SaveUiState.Failed -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||
@@ -178,7 +217,7 @@ fun EventEditScreen(
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||
}
|
||||
SaveUiState.Idle, SaveUiState.Saving, null -> Unit
|
||||
SaveUiState.Idle, SaveUiState.AwaitingScope, SaveUiState.Saving, null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +261,53 @@ fun EventEditScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||
SaveScopeDialog(
|
||||
recurrenceChanged = state?.recurrenceChanged == true,
|
||||
onSelect = viewModel::saveWithScope,
|
||||
onDismiss = viewModel::consumeSaveResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope choice when saving a dirty recurring event: one tap writes just the
|
||||
* tapped occurrence, it and everything after (series split), or the whole
|
||||
* series. A changed recurrence rule rules out the single occurrence — an
|
||||
* exception row can't carry its own rule.
|
||||
*/
|
||||
@Composable
|
||||
private fun SaveScopeDialog(
|
||||
recurrenceChanged: Boolean,
|
||||
onSelect: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!recurrenceChanged) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
||||
@@ -261,6 +347,7 @@ private fun EventEditContent(
|
||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -348,12 +435,15 @@ private fun EventEditContent(
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
|
||||
// Calendar card — tap anywhere to pick the target 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() },
|
||||
onClick = { showCalendarPicker = true }
|
||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
@@ -438,6 +528,46 @@ private fun EventEditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = { showRecurrencePicker = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = form.rrule?.let { recurrenceText(it, locale) }
|
||||
?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_recurrence),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_error_recurrence_ends_before_start),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
@@ -497,7 +627,7 @@ private fun EventEditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hasHiddenFields) {
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
onClick = { showFieldPicker = true },
|
||||
@@ -561,6 +691,18 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
startDay = form.start.date.dayOfWeek,
|
||||
onSelect = { rrule ->
|
||||
viewModel.setRecurrence(rrule)
|
||||
showRecurrencePicker = false
|
||||
},
|
||||
onDismiss = { showRecurrencePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showVisibilityPicker) {
|
||||
VisibilityPickerDialog(
|
||||
selected = form.accessLevel,
|
||||
@@ -574,7 +716,7 @@ private fun EventEditContent(
|
||||
|
||||
if (showFieldPicker) {
|
||||
FieldPickerDialog(
|
||||
hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields },
|
||||
hiddenFields = state.hiddenFields,
|
||||
onSelect = { field ->
|
||||
viewModel.revealField(field)
|
||||
showFieldPicker = false
|
||||
@@ -660,71 +802,17 @@ private fun ReminderPickerDialog(
|
||||
}
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// surfaceContainerHighest — the dialog itself sits on
|
||||
// surfaceContainerHigh, so anything lower vanishes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineField(
|
||||
value = amountText,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
||||
amountText = text
|
||||
}
|
||||
},
|
||||
placeholder = "10",
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
var unitMenuOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { unitMenuOpen = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(
|
||||
start = 14.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(reminderUnitLabel(unit)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = unitMenuOpen,
|
||||
onDismissRequest = { unitMenuOpen = false },
|
||||
) {
|
||||
ReminderUnit.entries.forEach { entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
onClick = {
|
||||
unit = entry
|
||||
unitMenuOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(reminderUnitLabel(unit)),
|
||||
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
onPick = { unit = ReminderUnit.entries[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -743,6 +831,351 @@ private fun ReminderPickerDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
||||
private enum class RecurrenceEndMode { Never, Until, Count }
|
||||
|
||||
/**
|
||||
* Recurrence picker, two steps like the reminder picker: the plain
|
||||
* frequencies as a tappable list (one tap applies and closes), with "Custom"
|
||||
* switching to an interval-plus-unit editor, weekday toggles (weekly only)
|
||||
* and an end condition — only that step needs an OK button. A rule the
|
||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||
* user picks something here.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecurrencePickerDialog(
|
||||
current: String?,
|
||||
startDay: DayOfWeek,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||
var customMode by rememberSaveable { mutableStateOf(false) }
|
||||
var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) }
|
||||
var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) }
|
||||
// The event's start weekday stands in until the user picks days herself.
|
||||
var daysMask by rememberSaveable {
|
||||
mutableStateOf(
|
||||
(parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask(),
|
||||
)
|
||||
}
|
||||
var endMode by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when (parsed?.end) {
|
||||
is RecurrenceEnd.Until -> RecurrenceEndMode.Until
|
||||
is RecurrenceEnd.Count -> RecurrenceEndMode.Count
|
||||
else -> RecurrenceEndMode.Never
|
||||
},
|
||||
)
|
||||
}
|
||||
var untilIso by rememberSaveable {
|
||||
mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString())
|
||||
}
|
||||
var countText by rememberSaveable {
|
||||
mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString())
|
||||
}
|
||||
var showUntilPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val locale = currentLocale()
|
||||
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||
}
|
||||
val customResult: String? = if (interval != null && customEnd != null) {
|
||||
SimpleRecurrence(
|
||||
freq = freq,
|
||||
interval = interval,
|
||||
end = customEnd,
|
||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||
).toRRule()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_detail_recurrence)) },
|
||||
text = {
|
||||
if (!customMode) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_none),
|
||||
onClick = { onSelect(null) },
|
||||
selected = current == null,
|
||||
)
|
||||
RecurrenceFreq.entries.forEach { entry ->
|
||||
OptionCard(
|
||||
label = stringResource(recurrencePresetLabel(entry)),
|
||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_custom),
|
||||
onClick = { customMode = true },
|
||||
selected = current != null && !isPlainPreset,
|
||||
labelColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_every),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogAmountField(
|
||||
value = intervalText,
|
||||
onValueChange = { intervalText = it },
|
||||
placeholder = "1",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(recurrenceUnitLabel(freq)),
|
||||
entries = RecurrenceFreq.entries.map {
|
||||
stringResource(recurrenceUnitLabel(it))
|
||||
},
|
||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||
)
|
||||
}
|
||||
if (freq == RecurrenceFreq.Weekly) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
WeekdayToggleRow(
|
||||
selected = daysMask.toDaySet(),
|
||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||
locale = locale,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_never),
|
||||
onClick = { endMode = RecurrenceEndMode.Never },
|
||||
selected = endMode == RecurrenceEndMode.Never,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_until),
|
||||
onClick = {
|
||||
endMode = RecurrenceEndMode.Until
|
||||
showUntilPicker = true
|
||||
},
|
||||
supportingText = untilDate?.let {
|
||||
remember(it, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale).format(it.toJavaLocalDate())
|
||||
}
|
||||
},
|
||||
selected = endMode == RecurrenceEndMode.Until,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_count),
|
||||
onClick = { endMode = RecurrenceEndMode.Count },
|
||||
selected = endMode == RecurrenceEndMode.Count,
|
||||
)
|
||||
if (endMode == RecurrenceEndMode.Count) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = countText,
|
||||
onValueChange = { countText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_times),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
// The preset list applies on tap; only the custom step needs OK.
|
||||
if (customMode) {
|
||||
TextButton(
|
||||
enabled = customResult != null,
|
||||
onClick = { customResult?.let(onSelect) },
|
||||
) { Text(stringResource(R.string.dialog_ok)) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
|
||||
if (showUntilPicker) {
|
||||
DatePickerAlert(
|
||||
initial = untilDate ?: LocalDate.fromEpochDays(
|
||||
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
||||
),
|
||||
onConfirm = {
|
||||
untilIso = it.toString()
|
||||
showUntilPicker = false
|
||||
},
|
||||
onDismiss = { showUntilPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tappable circle per weekday (locale week order), multi-select — the
|
||||
* BYDAY picks of a weekly rule. Deselecting every day is allowed; the rule
|
||||
* then falls back to the event's start weekday (provider behaviour).
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekdayToggleRow(
|
||||
selected: Set<DayOfWeek>,
|
||||
onToggle: (DayOfWeek) -> Unit,
|
||||
locale: Locale,
|
||||
) {
|
||||
val days = remember(locale) {
|
||||
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
||||
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
val isSelected = day in selected
|
||||
Surface(
|
||||
onClick = { onToggle(day) },
|
||||
shape = CircleShape,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
},
|
||||
contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */
|
||||
private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1)
|
||||
|
||||
private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() }
|
||||
|
||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||
|
||||
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
||||
@Composable
|
||||
private fun DialogAmountField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
) {
|
||||
// surfaceContainerHighest — the dialog itself sits on
|
||||
// surfaceContainerHigh, so anything lower vanishes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
||||
onValueChange(text)
|
||||
}
|
||||
},
|
||||
placeholder = placeholder,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
||||
@Composable
|
||||
private fun DialogUnitDropdown(
|
||||
label: String,
|
||||
entries: List<String>,
|
||||
onPick: (Int) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { open = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(
|
||||
start = 14.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry) },
|
||||
onClick = {
|
||||
onPick(index)
|
||||
open = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||
RecurrenceFreq.Weekly -> R.string.recurrence_weekly
|
||||
RecurrenceFreq.Monthly -> R.string.recurrence_monthly
|
||||
RecurrenceFreq.Yearly -> R.string.recurrence_yearly
|
||||
}
|
||||
|
||||
private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Daily -> R.string.recurrence_unit_days
|
||||
RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks
|
||||
RecurrenceFreq.Monthly -> R.string.recurrence_unit_months
|
||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||
}
|
||||
|
||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
||||
@Composable
|
||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
||||
@@ -793,6 +1226,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
}
|
||||
@@ -801,6 +1235,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
}
|
||||
|
||||
@@ -19,13 +19,26 @@ data class EventEditUiState(
|
||||
val saveState: SaveUiState,
|
||||
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
||||
val visibleFields: Set<EventFormField> = emptySet(),
|
||||
/** True while at least one optional section hides behind "more fields". */
|
||||
val hasHiddenFields: Boolean = false,
|
||||
/**
|
||||
* Optional sections behind "more fields". Sections the current mode can't
|
||||
* offer at all (recurrence while editing a single occurrence) appear in
|
||||
* neither list.
|
||||
*/
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
*/
|
||||
val recurrenceChanged: Boolean = false,
|
||||
)
|
||||
|
||||
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||
sealed interface SaveUiState {
|
||||
data object Idle : SaveUiState
|
||||
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||
data object AwaitingScope : SaveUiState
|
||||
data object Saving : SaveUiState
|
||||
data object Saved : SaveUiState
|
||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||
|
||||
@@ -12,9 +12,13 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -54,13 +58,32 @@ class EventEditViewModel @Inject constructor(
|
||||
// form isn't already shouting errors.
|
||||
private val _showProblems = MutableStateFlow(false)
|
||||
// Fields added through the "more fields" picker; folds back on reset().
|
||||
// openForEdit seeds it with the sections that already hold values.
|
||||
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
||||
// Set while the form edits an existing event instead of composing a new one.
|
||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||
private val _loadFailed = MutableStateFlow(false)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
|
||||
/**
|
||||
* The event being edited plus the form exactly as it was prefilled.
|
||||
* For recurring events the write scope is chosen at save time; the
|
||||
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
|
||||
*/
|
||||
private data class EditTarget(
|
||||
val eventId: Long,
|
||||
val original: EventForm,
|
||||
val beginMillis: Long,
|
||||
)
|
||||
|
||||
private data class LocalInputs(
|
||||
val form: EventForm?,
|
||||
val saveState: SaveUiState,
|
||||
val showProblems: Boolean,
|
||||
val revealed: Set<EventFormField>,
|
||||
val editTarget: EditTarget?,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -70,7 +93,7 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val state: StateFlow<EventEditUiState?> = combine(
|
||||
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
|
||||
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||
combine(
|
||||
repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||
@@ -92,7 +115,12 @@ class EventEditViewModel @Inject constructor(
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
resolved.rrule != local.editTarget.original.rrule,
|
||||
)
|
||||
}
|
||||
.flowOn(io)
|
||||
@@ -123,12 +151,38 @@ class EventEditViewModel @Inject constructor(
|
||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||
}
|
||||
|
||||
/** Forget the open form; the next [openNew] starts clean. */
|
||||
/**
|
||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||
* form is open, so user edits survive configuration changes.
|
||||
*/
|
||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
viewModelScope.launch {
|
||||
val detail = try {
|
||||
repository.eventDetail(eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_loadFailed.value = true
|
||||
return@launch
|
||||
}
|
||||
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
|
||||
_editTarget.value = EditTarget(eventId, original, beginMillis)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = original.populatedFields()
|
||||
_form.value = original
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||
fun reset() {
|
||||
_form.value = null
|
||||
_saveState.value = SaveUiState.Idle
|
||||
_showProblems.value = false
|
||||
_revealed.value = emptySet()
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
@@ -144,6 +198,9 @@ class EventEditViewModel @Inject constructor(
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
|
||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||
|
||||
fun addReminder(minutes: Int) = update {
|
||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||
}
|
||||
@@ -158,7 +215,12 @@ class EventEditViewModel @Inject constructor(
|
||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
|
||||
/** Validate and write. Terminal results land in [saveState]. */
|
||||
/**
|
||||
* Validate and write. Saving a dirty recurring event pauses in
|
||||
* [SaveUiState.AwaitingScope] until the screen answers via
|
||||
* [saveWithScope]; everything else writes directly. Terminal results
|
||||
* land in [saveState].
|
||||
*/
|
||||
fun save() {
|
||||
val current = state.value ?: return
|
||||
if (current.saveState == SaveUiState.Saving) return
|
||||
@@ -167,11 +229,49 @@ class EventEditViewModel @Inject constructor(
|
||||
_showProblems.value = true
|
||||
return
|
||||
}
|
||||
val target = _editTarget.value
|
||||
if (target != null && form == target.original) {
|
||||
// A pristine form saves as a no-op instead of a write.
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
if (target != null && target.original.rrule != null) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
performSave(form, RecurringWriteScope.AllEvents)
|
||||
}
|
||||
|
||||
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
|
||||
fun saveWithScope(scope: RecurringWriteScope) {
|
||||
val current = state.value ?: return
|
||||
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||
performSave(current.form, scope)
|
||||
}
|
||||
|
||||
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
|
||||
val target = _editTarget.value
|
||||
viewModelScope.launch {
|
||||
_saveState.value = SaveUiState.Saving
|
||||
_saveState.value = try {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else {
|
||||
when (scope) {
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.updateEventFromOccurrence(
|
||||
eventId = target.eventId,
|
||||
beginMillis = target.beginMillis,
|
||||
original = target.original,
|
||||
updated = form,
|
||||
)
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(target.eventId, target.original, form)
|
||||
}
|
||||
}
|
||||
SaveUiState.Saved
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -340,6 +340,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user