Compare commits
1 Commits
release/v2
...
f81f593017
| Author | SHA1 | Date | |
|---|---|---|---|
| f81f593017 |
@@ -3,15 +3,13 @@ name: Translations
|
|||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
#
|
|
||||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
|
||||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
|
||||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
|
||||||
# required check pending forever and blocks the merge of any code-only PR into a
|
|
||||||
# release/* branch. The check itself is cheap and simply passes when the
|
|
||||||
# committed translations are consistent, so always running it costs nothing.
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
- 'scripts/check_translations.py'
|
||||||
|
- '.gitea/workflows/translations.yaml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: translations-${{ github.ref }}
|
group: translations-${{ github.ref }}
|
||||||
|
|||||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -7,63 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
|
||||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
|
||||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
|
||||||
after exactly the delay you want instead of only a preset one ([#40]).
|
|
||||||
- Move an event to another calendar. When editing an existing event, the
|
|
||||||
calendar row is now tappable — pick a different calendar and saving moves the
|
|
||||||
event across, instead of having to delete it and recreate it elsewhere.
|
|
||||||
Recurring series move as a whole, keeping their individually-edited and
|
|
||||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
|
||||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
|
||||||
the target and removes the original — the same approach other calendar apps
|
|
||||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
|
||||||
- Open an event straight into the edit form from another app. Calendula already
|
|
||||||
answered the "new event" and "open this event" hand-offs from other apps and
|
|
||||||
widgets; it now also answers the "edit this event" one, so an assistant, task
|
|
||||||
app, or widget can send an existing event to Calendula and land on its edit
|
|
||||||
screen rather than the read-only details. A hand-off with no event attached
|
|
||||||
opens the same prefilled create form as "new event". Calendula also recognises
|
|
||||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
|
||||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
|
||||||
into Calendula works too.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Reminders for events on another day no longer read as if they were today. A
|
|
||||||
reminder fired ahead of time — say, the day before — used to show only the
|
|
||||||
event's time, making it look like it was happening now. The notification now
|
|
||||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
|
||||||
week, or the date for anything further out. Thanks to @moonj for the report
|
|
||||||
([#46]).
|
|
||||||
|
|
||||||
## [2.14.1] — 2026-07-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Deleting one occurrence of a repeating event no longer breaks the series.
|
|
||||||
Choosing "This event" when deleting an occurrence of a recurring event could
|
|
||||||
wipe out every *other* occurrence while leaving the one you deleted behind as
|
|
||||||
a stale, still-tappable ghost — and deleting it again brought the series back.
|
|
||||||
A single-occurrence delete now removes exactly that occurrence and leaves the
|
|
||||||
rest of the series untouched, and the deleted occurrence disappears from the
|
|
||||||
grid straight away. This holds on every kind of calendar, including the
|
|
||||||
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
|
||||||
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
|
||||||
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
|
||||||
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
|
||||||
it also answers the "open this event" one, so tapping an existing event in a
|
|
||||||
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
|
||||||
Thanks to @bushrang3r for the report ([#48]).
|
|
||||||
- Events created from other apps get your default reminder. An event handed over
|
|
||||||
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
|
||||||
widget's "+" — opened with no reminder at all, ignoring the default set in
|
|
||||||
Settings. It now starts with your default reminder, the same as an event you
|
|
||||||
create in Calendula. An event opened from an `.ics` file is treated differently,
|
|
||||||
because the file has its own say: Calendula keeps whatever reminders it carries
|
|
||||||
(including none at all) and asks you once whether to apply your default instead
|
|
||||||
— it never quietly overrides the file. If you have no default set, it doesn't
|
|
||||||
ask ([#49]).
|
|
||||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
app (such as DAVx5), the event colour picker showed every colour the account
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
@@ -944,9 +888,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
|
||||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
|
||||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
|
||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21401
|
versionCode = 21400
|
||||||
versionName = "2.14.1"
|
versionName = "2.14.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,74 +97,32 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
The three MIME types cover the common labels the same calendar
|
|
||||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
|
||||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
|
||||||
mail apps emit — Android cross-products the scheme and mimeType
|
|
||||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" />
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
<data android:scheme="file" />
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/calendar" />
|
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
<!-- Receive a .ics shared from another app. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||||
to create a new event, the way the AOSP calendar accepts it:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events dir mime type, carrying the new
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
event's fields as CalendarContract extras
|
event's fields as CalendarContract extras
|
||||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
(MainActivity.insertFormOrNull, issue #30). -->
|
||||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
|
||||||
to the same prefilled create form. -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.INSERT" />
|
<action android:name="android.intent.action.INSERT" />
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Edit an existing event another app/assistant/widget points at:
|
|
||||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
|
||||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
|
||||||
read-only detail — that's the VIEW filter above). Matched by the
|
|
||||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
|
||||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
|
||||||
supplied (MainActivity.editEventKeyOrNull). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
|
||||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
|
||||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
|
||||||
Matched by the provider's item MIME type, not the path — a
|
|
||||||
content: VIEW intent carries the resolved type
|
|
||||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
|
||||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
|
||||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
|
||||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
|
||||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -70,11 +69,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
|
||||||
// detail channel; consumed once by CalendarHost.
|
|
||||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the calendar on the next launch (the single-crash path). A startup
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -96,11 +90,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -151,8 +144,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -187,11 +178,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,19 +202,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A prefilled new-event form from an external launch asking us to create an
|
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
|
||||||
* [buildInsertEventForm].
|
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
if (action != Intent.ACTION_INSERT) return null
|
||||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
|
||||||
val isCreate = action == Intent.ACTION_INSERT ||
|
|
||||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
|
||||||
if (!isCreate) return null
|
|
||||||
return buildInsertEventForm(
|
return buildInsertEventForm(
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
@@ -309,55 +293,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
|
||||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
|
||||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
|
||||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
|
||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
|
||||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
|
||||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
|
||||||
* rather than rendering at the epoch.
|
|
||||||
*/
|
|
||||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
|
||||||
if (action != Intent.ACTION_VIEW) return null
|
|
||||||
val uri = data ?: return null
|
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
|
||||||
val segments = uri.pathSegments
|
|
||||||
if (segments.firstOrNull() != "events") return null
|
|
||||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
|
||||||
return longArrayOf(
|
|
||||||
eventId,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
|
||||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
|
||||||
* an assistant, task app, or widget that wants to open the event for editing
|
|
||||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
|
||||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
|
||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
|
||||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
|
||||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
|
||||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
|
||||||
*/
|
|
||||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
|
||||||
if (action != Intent.ACTION_EDIT) return null
|
|
||||||
val uri = data ?: return null
|
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
|
||||||
val segments = uri.pathSegments
|
|
||||||
if (segments.firstOrNull() != "events") return null
|
|
||||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
|
||||||
return longArrayOf(
|
|
||||||
eventId,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
@@ -181,25 +179,6 @@ interface CalendarDataSource {
|
|||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Move an event (for recurring events: the whole series, with its modified
|
|
||||||
* and cancelled occurrences) to [targetCalendarId], returning the new
|
|
||||||
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
|
||||||
* place, so this is copy+delete: the row is re-inserted on the target (its
|
|
||||||
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
|
||||||
* replayed, the [original]→[updated] field edits applied, then the source
|
|
||||||
* deleted. The source is removed only once the copy fully succeeds; a failure
|
|
||||||
* before that rolls the new event back, leaving the original untouched.
|
|
||||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
|
||||||
*/
|
|
||||||
fun moveEvent(
|
|
||||||
eventId: Long,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
@@ -516,13 +495,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
// Hide cancelled occurrences: "delete only this event" writes a
|
null, null,
|
||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
|
||||||
null,
|
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -906,190 +879,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun moveEvent(
|
|
||||||
eventId: Long,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
|
||||||
// target calendar and delete the source. Everything that identifies or
|
|
||||||
// hangs off the event is copied first; the source row is removed only
|
|
||||||
// once the copy (including its exceptions) is complete — a failure
|
|
||||||
// anywhere before that rolls the new event back, so the move is
|
|
||||||
// all-or-nothing and never leaves a half-copied duplicate.
|
|
||||||
val master = queryMoveMaster(eventId)
|
|
||||||
?: throw WriteFailedException("read event to move id=$eventId")
|
|
||||||
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
|
||||||
// recognise the moved event; mint one only if the source carried none.
|
|
||||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
|
||||||
val newEventId = resolver.insert(
|
|
||||||
CalendarContract.Events.CONTENT_URI,
|
|
||||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
|
||||||
)?.let(ContentUris::parseId)
|
|
||||||
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
|
||||||
try {
|
|
||||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
|
||||||
insertAttendees(newEventId, editableAttendees(eventId))
|
|
||||||
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
|
||||||
// Apply the user's field edits exactly as a same-calendar "all
|
|
||||||
// events" save would: the moved master carries the source's values,
|
|
||||||
// so the dirty diff against [original] writes only what changed
|
|
||||||
// (including a time or rrule edit made in the same save).
|
|
||||||
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
// Undo the partial copy so a failed move leaves nothing behind; the
|
|
||||||
// source is still intact (it's deleted only past this point).
|
|
||||||
runCatching { deleteEvent(newEventId) }
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
deleteEvent(eventId)
|
|
||||||
return newEventId
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
|
||||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
MoveMasterProjection.COLUMNS,
|
|
||||||
null, null, null,
|
|
||||||
)?.use { c ->
|
|
||||||
if (!c.moveToFirst()) return@use null
|
|
||||||
val r = CursorColumnReader(c)
|
|
||||||
MoveMaster(
|
|
||||||
uid = r.getString(MoveMasterProjection.IDX_UID),
|
|
||||||
event = MasterEventSnapshot(
|
|
||||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
|
||||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
|
||||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
|
||||||
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
|
||||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
|
||||||
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
|
||||||
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
|
||||||
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
|
||||||
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
|
||||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
|
||||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
|
||||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
|
||||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
|
||||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
|
||||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
|
||||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
|
||||||
|
|
||||||
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
|
||||||
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
|
||||||
CalendarContract.Reminders.CONTENT_URI,
|
|
||||||
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
|
||||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
|
||||||
arrayOf(fromEventId.toString()),
|
|
||||||
null,
|
|
||||||
)?.use { c ->
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
|
||||||
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
|
||||||
}
|
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
|
||||||
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: Unit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The event's editable guests as [EventAttendee]s — the rows the move
|
|
||||||
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
|
||||||
* rows (backend-owned, not user-editable) and any without an email are
|
|
||||||
* dropped; the response status resets to "invited" on re-insert.
|
|
||||||
*/
|
|
||||||
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
|
||||||
.filter {
|
|
||||||
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
|
||||||
}
|
|
||||||
.mapNotNull { a ->
|
|
||||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
|
||||||
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replay every exception of the series [fromEventId] against the moved series
|
|
||||||
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
|
||||||
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
|
||||||
* exception, a modified one is re-inserted with its fields (then its reminders
|
|
||||||
* reconciled and editable guests copied). No-op for a non-recurring event.
|
|
||||||
*/
|
|
||||||
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
|
||||||
queryExceptionRows(fromEventId).forEach { ex ->
|
|
||||||
if (ex.isCancelled) {
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
}
|
|
||||||
resolver.insert(
|
|
||||||
ContentUris.withAppendedId(
|
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
|
||||||
),
|
|
||||||
values,
|
|
||||||
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
|
||||||
} else {
|
|
||||||
val uri = resolver.insert(
|
|
||||||
ContentUris.withAppendedId(
|
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
|
||||||
),
|
|
||||||
buildCopiedExceptionValues(ex).toContentValues(),
|
|
||||||
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
|
||||||
val newExceptionId = ContentUris.parseId(uri)
|
|
||||||
// The provider may clone the parent's reminders onto the new
|
|
||||||
// exception; reconcile to the source exception's exact set so
|
|
||||||
// they neither double nor drop. Same DTSTART → same all-day
|
|
||||||
// encoding, so the stored offsets match directly.
|
|
||||||
reconcileReminders(
|
|
||||||
newExceptionId,
|
|
||||||
queryReminders(ex.exceptionEventId).map { it.minutes },
|
|
||||||
)
|
|
||||||
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
|
||||||
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
|
||||||
CalendarContract.Events.CONTENT_URI,
|
|
||||||
ExceptionProjection.COLUMNS,
|
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
|
||||||
arrayOf(seriesEventId.toString()),
|
|
||||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
|
||||||
)?.use { c ->
|
|
||||||
c.mapAll {
|
|
||||||
val r = CursorColumnReader(c)
|
|
||||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
|
||||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
|
||||||
ExceptionRowSnapshot(
|
|
||||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
|
||||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
|
||||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
|
||||||
status = status,
|
|
||||||
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
|
||||||
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
|
||||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
|
||||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
|
||||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
|
||||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
|
||||||
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
|
||||||
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
|
||||||
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
|
||||||
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
|
||||||
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -1184,8 +973,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events._SYNC_ID,
|
|
||||||
CalendarContract.Events.EXDATE,
|
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -1196,8 +983,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
syncId = c.getString(5),
|
|
||||||
exdate = c.getString(6),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1210,9 +995,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
val allDay: Int,
|
||||||
/** Null on a local calendar (and before a synced event's first push). */
|
|
||||||
val syncId: String? = null,
|
|
||||||
val exdate: String? = null,
|
|
||||||
) {
|
) {
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1403,49 +1185,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
if (row.syncId == null) {
|
|
||||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
|
||||||
// A cancelled exception can only attach to its parent through
|
|
||||||
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
|
||||||
// provider's expansion of the *parent* collapses, taking every other
|
|
||||||
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
|
||||||
// Calendula's own contact special-date calendars are local and hold
|
|
||||||
// yearly series, so this path is reached in normal use.
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrenceMillis = beginMillis,
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
val updated = resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
if (updated == 0) {
|
|
||||||
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||||
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
val values = ContentValues().apply {
|
||||||
// single instance rather than cloning the master's RRULE — the same trap
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||||
// the edit path documents (Codeberg #16).
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
val values = buildOccurrenceCancelValues(
|
}
|
||||||
originalInstanceMillis = beginMillis,
|
|
||||||
dtStartMillis = beginMillis,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
)
|
)
|
||||||
resolver.insert(uri, values.toContentValues())
|
resolver.insert(uri, values)
|
||||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,20 +61,6 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
/**
|
|
||||||
* Move an event (recurring: the whole series, with its exceptions) to
|
|
||||||
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
|
||||||
* the new event's `Events._ID`. Copy+delete under the hood
|
|
||||||
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
|
||||||
* place.
|
|
||||||
*/
|
|
||||||
suspend fun moveEvent(
|
|
||||||
eventId: Long,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
): Long
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
|||||||
@@ -157,17 +157,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun moveEvent(
|
|
||||||
eventId: Long,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
): Long = withContext(io) {
|
|
||||||
dataSource.moveEvent(
|
|
||||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -37,18 +36,15 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
// specific occurrence supply the real per-occurrence times from
|
||||||
// both times with the per-occurrence values from CalendarContract.Instances;
|
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||||
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
// but dropping the row would make the event un-openable — the same trap as
|
||||||
// intent, issue #48) keeps this row's own times, so the length has to be
|
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
||||||
// right here or the series renders zero-length. A present-but-backwards
|
// generic error screen with no way to open the event and fix it. Clamp to a
|
||||||
// DTEND is malformed, but dropping the row would make the event un-openable
|
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
||||||
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
|
||||||
// surface as the generic error screen with no way to open the event and fix
|
|
||||||
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
begin
|
||||||
} else {
|
} else {
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,19 +43,10 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||||
|
|
||||||
/**
|
|
||||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
|
||||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
|
||||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
|
||||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
|
||||||
*/
|
|
||||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
|
||||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
|
||||||
} else {
|
} else {
|
||||||
"P${spanMillis / 1_000L}S"
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,215 +182,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
|
||||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
|
||||||
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
|
||||||
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
|
||||||
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
|
||||||
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
|
||||||
* account, so the moved copy inherits the target calendar's colour instead.
|
|
||||||
*/
|
|
||||||
internal data class MasterEventSnapshot(
|
|
||||||
val title: String,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val dtStartMillis: Long,
|
|
||||||
val dtEndMillis: Long?,
|
|
||||||
val duration: String?,
|
|
||||||
val rrule: String?,
|
|
||||||
val rdate: String?,
|
|
||||||
val exdate: String?,
|
|
||||||
val timezone: String?,
|
|
||||||
val availability: Int,
|
|
||||||
val accessLevel: Int,
|
|
||||||
val status: Int?,
|
|
||||||
val location: String?,
|
|
||||||
val description: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Column values re-creating [snapshot] as a fresh Events row on
|
|
||||||
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
|
||||||
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
|
||||||
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
|
||||||
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
|
||||||
* caller layers the user's field edits on top with a normal series update.
|
|
||||||
*/
|
|
||||||
internal fun buildMovedMasterValues(
|
|
||||||
snapshot: MasterEventSnapshot,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
uid: String,
|
|
||||||
): Map<String, Any?> = buildMap {
|
|
||||||
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
|
||||||
put(CalendarContract.Events.UID_2445, uid)
|
|
||||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
|
||||||
if (snapshot.rrule != null) {
|
|
||||||
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
|
||||||
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
|
||||||
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
|
||||||
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
|
||||||
} else {
|
|
||||||
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
|
||||||
}
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
|
||||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
|
||||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
|
||||||
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
|
||||||
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw provider snapshot of one exception row of a recurring series (a modified
|
|
||||||
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
|
||||||
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
|
||||||
*/
|
|
||||||
internal data class ExceptionRowSnapshot(
|
|
||||||
val exceptionEventId: Long,
|
|
||||||
val originalInstanceMillis: Long,
|
|
||||||
val isCancelled: Boolean,
|
|
||||||
val status: Int?,
|
|
||||||
val title: String,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val dtStartMillis: Long,
|
|
||||||
val dtEndMillis: Long?,
|
|
||||||
val duration: String?,
|
|
||||||
val timezone: String?,
|
|
||||||
val availability: Int,
|
|
||||||
val accessLevel: Int,
|
|
||||||
val location: String?,
|
|
||||||
val description: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
|
||||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
|
||||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
|
||||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
|
||||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
|
||||||
*/
|
|
||||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
|
||||||
buildMap {
|
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
|
||||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
|
||||||
?: rfc2445Duration(
|
|
||||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
|
||||||
snapshot.isAllDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
|
||||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
|
||||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
|
||||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
|
||||||
* provider clone the series row and cancel exactly this one instance.
|
|
||||||
*
|
|
||||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
|
||||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
|
||||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
|
||||||
* the *whole series* is cancelled and every other occurrence disappears
|
|
||||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
|
||||||
* — cancelling never changes them.
|
|
||||||
*/
|
|
||||||
internal fun buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis: Long,
|
|
||||||
dtStartMillis: Long,
|
|
||||||
duration: String?,
|
|
||||||
timezone: String?,
|
|
||||||
allDay: Int,
|
|
||||||
): Map<String, Any?> = buildMap {
|
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
|
||||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
|
||||||
put(CalendarContract.Events.DURATION, duration)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
|
||||||
* series by adding it to `EXDATE` — the path for events that have **no
|
|
||||||
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
|
||||||
*
|
|
||||||
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
|
||||||
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
|
||||||
* forms, and the provider's expansion of the *parent* collapses — every other
|
|
||||||
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
|
||||||
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
|
||||||
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
|
||||||
*
|
|
||||||
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
|
||||||
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
|
||||||
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
|
||||||
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
|
||||||
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
|
||||||
* `lastDate` as if the event were a single instance, collapsing the series to its
|
|
||||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
|
||||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
|
||||||
*
|
|
||||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
|
||||||
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
|
||||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
|
||||||
*/
|
|
||||||
internal fun buildOccurrenceExdateValues(
|
|
||||||
existingExdate: String?,
|
|
||||||
occurrenceMillis: Long,
|
|
||||||
dtStartMillis: Long,
|
|
||||||
rrule: String?,
|
|
||||||
duration: String?,
|
|
||||||
timezone: String?,
|
|
||||||
allDay: Int,
|
|
||||||
): Map<String, Any?> {
|
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
|
||||||
val existing = existingExdate?.split(',')
|
|
||||||
?.map { it.trim() }
|
|
||||||
?.filter { it.isNotEmpty() }
|
|
||||||
.orEmpty()
|
|
||||||
val merged = (existing + stamp).distinct().joinToString(",")
|
|
||||||
return mapOf(
|
|
||||||
CalendarContract.Events.EXDATE to merged,
|
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
|
||||||
CalendarContract.Events.RRULE to rrule,
|
|
||||||
CalendarContract.Events.DURATION to duration,
|
|
||||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
|
||||||
CalendarContract.Events.ALL_DAY to allDay,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
|
||||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
|
||||||
* reads off the UTC calendar day.
|
|
||||||
*/
|
|
||||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
|
||||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
|
||||||
return if (isAllDay) {
|
|
||||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
|
||||||
} else {
|
|
||||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
|
||||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
|
||||||
utc.hour, utc.minute, utc.second,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -84,9 +84,6 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
|
||||||
// needs it to render a series opened without a named occurrence.
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -107,7 +104,6 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
const val IDX_DURATION = 18
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,86 +186,6 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The master/one-off Events row of an event about to be moved to another
|
|
||||||
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
|
||||||
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
|
||||||
* generates the same instances, and `UID_2445` so identity survives the move.
|
|
||||||
*/
|
|
||||||
internal object MoveMasterProjection {
|
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
|
||||||
CalendarContract.Events.UID_2445,
|
|
||||||
CalendarContract.Events.TITLE,
|
|
||||||
CalendarContract.Events.DTSTART,
|
|
||||||
CalendarContract.Events.DTEND,
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
CalendarContract.Events.RRULE,
|
|
||||||
CalendarContract.Events.RDATE,
|
|
||||||
CalendarContract.Events.EXDATE,
|
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
|
||||||
CalendarContract.Events.ALL_DAY,
|
|
||||||
CalendarContract.Events.AVAILABILITY,
|
|
||||||
CalendarContract.Events.ACCESS_LEVEL,
|
|
||||||
CalendarContract.Events.STATUS,
|
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
|
||||||
CalendarContract.Events.DESCRIPTION,
|
|
||||||
)
|
|
||||||
|
|
||||||
const val IDX_UID = 0
|
|
||||||
const val IDX_TITLE = 1
|
|
||||||
const val IDX_DTSTART = 2
|
|
||||||
const val IDX_DTEND = 3
|
|
||||||
const val IDX_DURATION = 4
|
|
||||||
const val IDX_RRULE = 5
|
|
||||||
const val IDX_RDATE = 6
|
|
||||||
const val IDX_EXDATE = 7
|
|
||||||
const val IDX_EVENT_TIMEZONE = 8
|
|
||||||
const val IDX_ALL_DAY = 9
|
|
||||||
const val IDX_AVAILABILITY = 10
|
|
||||||
const val IDX_ACCESS_LEVEL = 11
|
|
||||||
const val IDX_STATUS = 12
|
|
||||||
const val IDX_LOCATION = 13
|
|
||||||
const val IDX_DESCRIPTION = 14
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
|
||||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
|
||||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
|
||||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
|
||||||
*/
|
|
||||||
internal object ExceptionProjection {
|
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
|
||||||
CalendarContract.Events._ID,
|
|
||||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
|
||||||
CalendarContract.Events.STATUS,
|
|
||||||
CalendarContract.Events.TITLE,
|
|
||||||
CalendarContract.Events.ALL_DAY,
|
|
||||||
CalendarContract.Events.DTSTART,
|
|
||||||
CalendarContract.Events.DTEND,
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
|
||||||
CalendarContract.Events.AVAILABILITY,
|
|
||||||
CalendarContract.Events.ACCESS_LEVEL,
|
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
|
||||||
CalendarContract.Events.DESCRIPTION,
|
|
||||||
)
|
|
||||||
|
|
||||||
const val IDX_ID = 0
|
|
||||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
|
||||||
const val IDX_STATUS = 2
|
|
||||||
const val IDX_TITLE = 3
|
|
||||||
const val IDX_ALL_DAY = 4
|
|
||||||
const val IDX_DTSTART = 5
|
|
||||||
const val IDX_DTEND = 6
|
|
||||||
const val IDX_DURATION = 7
|
|
||||||
const val IDX_EVENT_TIMEZONE = 8
|
|
||||||
const val IDX_AVAILABILITY = 9
|
|
||||||
const val IDX_ACCESS_LEVEL = 10
|
|
||||||
const val IDX_LOCATION = 11
|
|
||||||
const val IDX_DESCRIPTION = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.isoDayNumber
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -59,22 +55,13 @@ class ReminderNotifier @Inject constructor(
|
|||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
val zone = ZoneId.systemDefault()
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
|
||||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
|
||||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
|
||||||
val time = reminderTimeText(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = zone,
|
zone = ZoneId.systemDefault(),
|
||||||
locale = locale,
|
locale = Locale.getDefault(),
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
today = Instant.now().atZone(zone).toLocalDate(),
|
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
|
||||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
|
||||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line of time context in a reminder notification. Pure so it can be
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
* JVM-tested.
|
* JVM-tested:
|
||||||
*
|
*
|
||||||
* Timed events that fall on a day other than [today] are prefixed with that
|
* - timed, same day: "09:30 – 10:00"
|
||||||
* day, so a reminder fired ahead of time no longer reads as if the event were
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||||
* today (issue #46). The prefix prefers natural language and stays short:
|
|
||||||
*
|
|
||||||
* - today: "09:30 – 10:00" (no prefix)
|
|
||||||
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
|
||||||
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
|
||||||
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
|
||||||
* alone would be ambiguous)
|
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
|
||||||
* already unambiguous)
|
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "11 Jun 2026"
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
*
|
*
|
||||||
* All-day instances already carry an explicit date, so they never gain a
|
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
|
||||||
* read in UTC and the end day is the last *covered* day.
|
* read in UTC and the end day is the last *covered* day.
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
@@ -41,10 +27,6 @@ fun reminderTimeText(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
today: LocalDate,
|
|
||||||
firstDayOfWeek: DayOfWeek,
|
|
||||||
tomorrowLabel: String,
|
|
||||||
yesterdayLabel: String,
|
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -61,70 +43,18 @@ fun reminderTimeText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
|
||||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
val prefix = relativeDayPrefix(
|
|
||||||
day = begin.toLocalDate(),
|
|
||||||
today = today,
|
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
|
||||||
locale = locale,
|
|
||||||
dateFormat = dateFormat,
|
|
||||||
tomorrowLabel = tomorrowLabel,
|
|
||||||
yesterdayLabel = yesterdayLabel,
|
|
||||||
)
|
|
||||||
if (prefix == null) range else "$prefix, $range"
|
|
||||||
} else {
|
} else {
|
||||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
// applies to the time portion too. The explicit dates already say which
|
// applies to the time portion too.
|
||||||
// day, so no relative prefix is layered on top.
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||||
dateTime(begin) + RANGE + dateTime(end)
|
dateTime(begin) + RANGE + dateTime(end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
|
||||||
* common case, which needs no prefix). Weekday names are used only within the
|
|
||||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
|
||||||
* anything past this week falls back to the exact date.
|
|
||||||
*/
|
|
||||||
private fun relativeDayPrefix(
|
|
||||||
day: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
firstDayOfWeek: DayOfWeek,
|
|
||||||
locale: Locale,
|
|
||||||
dateFormat: DateTimeFormatter,
|
|
||||||
tomorrowLabel: String,
|
|
||||||
yesterdayLabel: String,
|
|
||||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
|
||||||
0L -> null
|
|
||||||
1L -> tomorrowLabel
|
|
||||||
-1L -> yesterdayLabel
|
|
||||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
|
||||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
|
||||||
} else {
|
|
||||||
dateFormat.format(day)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [day] and [today] share the same week. The week boundary honours the
|
|
||||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
|
||||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
|
||||||
*/
|
|
||||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
|
||||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
|
||||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
|
||||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
|
||||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
|
||||||
return minusDays(backtrack.toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val RANGE = " – "
|
private const val RANGE = " – "
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -75,8 +74,6 @@ fun CalendarHost(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -175,9 +172,6 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
|
||||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
|
||||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
|
||||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
@@ -196,7 +190,6 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = ImportSource.Insert
|
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -214,20 +207,6 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
|
||||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
|
||||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
|
||||||
// without this it would open hidden underneath them. Same held-key pattern as
|
|
||||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
|
||||||
LaunchedEffect(requestedEditKey) {
|
|
||||||
if (requestedEditKey != null) {
|
|
||||||
dismissCoveringOverlays()
|
|
||||||
heldEditKey = requestedEditKey
|
|
||||||
editKey = requestedEditKey
|
|
||||||
onEditKeyConsumed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -456,7 +435,6 @@ fun CalendarHost(
|
|||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -465,7 +443,6 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
initialFormSource = importFormSource,
|
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ fun RootScreen(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -90,8 +88,6 @@ fun RootScreen(
|
|||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -17,12 +29,13 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -182,19 +195,57 @@ private fun CustomReminderEditor(
|
|||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
CustomAmountEditor(
|
Surface(
|
||||||
amountText = amountText,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
onAmountChange = onAmountChange,
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
selectedUnit = unit.ordinal,
|
modifier = Modifier
|
||||||
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
.fillMaxWidth()
|
||||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
.padding(horizontal = 16.dp),
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
) {
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
Column(
|
||||||
confirmEnabled = amount != null,
|
modifier = Modifier.padding(16.dp),
|
||||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -313,134 +364,39 @@ private fun CustomDaysEditor(
|
|||||||
) {
|
) {
|
||||||
val days = amountText.toIntOrNull()
|
val days = amountText.toIntOrNull()
|
||||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
CustomAmountEditor(
|
Surface(
|
||||||
amountText = amountText,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
onAmountChange = onAmountChange,
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
placeholder = "30",
|
placeholder = "30",
|
||||||
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
?: stringResource(R.string.agenda_range_custom_hint),
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
confirmEnabled = days != null,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onConfirm = { days?.let(onConfirm) },
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
/**
|
onClick = { days?.let(onConfirm) },
|
||||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
enabled = days != null,
|
||||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
|
||||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
|
||||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
|
||||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
|
||||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
|
||||||
* is reused for both the rows and the custom preview.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SnoozeDurationPicker(
|
|
||||||
title: String,
|
|
||||||
presets: List<Int>,
|
|
||||||
selected: Int,
|
|
||||||
label: @Composable (Int) -> String,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val customSelected = selected !in presets
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
val rowCount = presets.size + 1 // + the custom row
|
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var amountText by rememberSaveable {
|
|
||||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
|
||||||
}
|
|
||||||
var unit by rememberSaveable {
|
|
||||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
|
||||||
presets.forEachIndexed { index, minute ->
|
|
||||||
val isSelected = minute == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = label(minute),
|
|
||||||
position = positionOf(index, rowCount),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(minute)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The Custom row connects downward into the editor card when expanded, so
|
|
||||||
// the two read as one grouped container (the shared custom-expand pattern).
|
|
||||||
GroupedRow(
|
|
||||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
|
||||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
|
||||||
selected = customSelected,
|
|
||||||
trailing = if (customSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { customExpanded = !customExpanded },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = customExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
|
||||||
CustomSnoozeEditor(
|
|
||||||
amountText = amountText,
|
|
||||||
onAmountChange = { amountText = it },
|
|
||||||
unit = unit,
|
|
||||||
onUnitChange = { unit = it },
|
|
||||||
label = label,
|
|
||||||
onConfirm = { minutes ->
|
|
||||||
onSelect(minutes)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole hours if the delay divides evenly, else minutes. */
|
|
||||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
|
||||||
|
|
||||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
|
||||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
|
||||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
|
||||||
* amount. [onConfirm] receives the final delay in minutes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CustomSnoozeEditor(
|
|
||||||
amountText: String,
|
|
||||||
onAmountChange: (String) -> Unit,
|
|
||||||
unit: ReminderUnit,
|
|
||||||
onUnitChange: (ReminderUnit) -> Unit,
|
|
||||||
label: @Composable (Int) -> String,
|
|
||||||
onConfirm: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
|
||||||
CustomAmountEditor(
|
|
||||||
amountText = amountText,
|
|
||||||
onAmountChange = onAmountChange,
|
|
||||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
|
||||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
|
||||||
onUnitChange = { onUnitChange(units[it]) },
|
|
||||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
|
||||||
confirmEnabled = amount != null,
|
|
||||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
|
|||||||
@@ -141,20 +141,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
// occurrence's time so recurring events render correctly. An external
|
// occurrence's time so recurring events render correctly.
|
||||||
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
val corrected = detail.copy(
|
||||||
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
|
||||||
// own DTSTART/DTEND instead of overriding it to the epoch.
|
|
||||||
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
|
||||||
detail
|
|
||||||
} else {
|
|
||||||
detail.copy(
|
|
||||||
instance = detail.instance.copy(
|
instance = detail.instance.copy(
|
||||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
val calendar = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
EventDetailUiState.Success(
|
EventDetailUiState.Success(
|
||||||
@@ -175,16 +168,6 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Sentinel begin/end for an "open this event" that names no occurrence —
|
|
||||||
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
|
||||||
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
|
||||||
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
|
||||||
*/
|
|
||||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -183,14 +182,12 @@ fun EventEditScreen(
|
|||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
when {
|
when {
|
||||||
// A prefilled open: a single-event .ics for review, or an external
|
// Single-event .ics open: the form arrives prefilled for review.
|
||||||
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
initialForm != null -> viewModel.openImported(initialForm)
|
||||||
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
|
|
||||||
editKey != null -> viewModel.openForEdit(
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
@@ -205,7 +202,6 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// The form is intentionally forgotten on every close (cancel or save) so
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
// the next open starts clean; it survives rotation because openNew /
|
// the next open starts clean; it survives rotation because openNew /
|
||||||
@@ -336,56 +332,6 @@ fun EventEditScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A .ics import respects the file's reminders, but offers to swap in the
|
|
||||||
// configured default rather than silently deciding for the user (#49).
|
|
||||||
importReminderPrompt?.let { prompt ->
|
|
||||||
ImportReminderPromptDialog(
|
|
||||||
currentReminderCount = prompt.currentReminderCount,
|
|
||||||
onApply = viewModel::applyImportedReminderDefault,
|
|
||||||
onKeep = viewModel::dismissImportedReminderPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
|
||||||
* file. The file's own reminders are kept unless the user accepts. A plain
|
|
||||||
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ImportReminderPromptDialog(
|
|
||||||
currentReminderCount: Int,
|
|
||||||
onApply: () -> Unit,
|
|
||||||
onKeep: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onKeep,
|
|
||||||
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (currentReminderCount == 0) {
|
|
||||||
stringResource(R.string.import_reminder_prompt_body_none)
|
|
||||||
} else {
|
|
||||||
pluralStringResource(
|
|
||||||
R.plurals.import_reminder_prompt_body_existing,
|
|
||||||
currentReminderCount,
|
|
||||||
currentReminderCount,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onApply) {
|
|
||||||
Text(stringResource(R.string.import_reminder_prompt_apply))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onKeep) {
|
|
||||||
Text(stringResource(R.string.import_reminder_prompt_keep))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -672,16 +618,15 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
|
|
||||||
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
// Calendar card — tap anywhere to pick the target calendar. Editing
|
||||||
// different one while editing *moves* the event (copy+delete on save,
|
// keeps the owning calendar (moving events between calendars is a
|
||||||
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
// sync-adapter minefield; every stock calendar app locks it too).
|
||||||
// events stay locked: the contact sync owns their calendar.
|
|
||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
iconTint = accent,
|
iconTint = accent,
|
||||||
onClick = { showCalendarPicker = true }
|
onClick = { showCalendarPicker = true }
|
||||||
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -51,36 +50,6 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
|
||||||
* sources want different reminder handling (#49).
|
|
||||||
*/
|
|
||||||
enum class ImportSource {
|
|
||||||
/**
|
|
||||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
|
||||||
* It carries no reminders of its own, so the settings default is applied
|
|
||||||
* automatically, exactly like an in-app new event.
|
|
||||||
*/
|
|
||||||
Insert,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
|
||||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
|
||||||
* rather than silently applied or suppressed.
|
|
||||||
*/
|
|
||||||
File,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
|
||||||
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
|
||||||
* [defaultReminders] is what accepting would set.
|
|
||||||
*/
|
|
||||||
data class ImportReminderPrompt(
|
|
||||||
val currentReminderCount: Int,
|
|
||||||
val defaultReminders: List<Int>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -109,16 +78,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
|
||||||
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -149,17 +112,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
) {
|
|
||||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
|
||||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
|
||||||
timedGlobal = timed,
|
|
||||||
allDayGlobal = allDay,
|
|
||||||
timedOverrides = timedOverrides,
|
|
||||||
allDayOverrides = allDayOverrides,
|
|
||||||
calendarId = calendarId,
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -288,39 +241,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
* calendar still resolves to the last-used/first-writable one.
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
*
|
* form is already open, so the prefill survives configuration changes.
|
||||||
* Reminders are handled per [source], because the two paths mean different
|
|
||||||
* things by "no reminders":
|
|
||||||
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
|
||||||
* default is applied automatically like an in-app new event (a form that
|
|
||||||
* somehow did carry reminders keeps them, frozen).
|
|
||||||
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
|
||||||
* settings default is configured and differs, [importReminderPrompt] offers
|
|
||||||
* to swap it in rather than silently deciding for the user.
|
|
||||||
*
|
|
||||||
* No-op when a form is already open, so the prefill survives configuration
|
|
||||||
* changes.
|
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm, source: ImportSource) {
|
fun openImported(form: EventForm) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
when (source) {
|
|
||||||
ImportSource.Insert ->
|
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
|
||||||
else applyDefaultReminder()
|
|
||||||
|
|
||||||
ImportSource.File -> {
|
|
||||||
// Respect the file's own reminders; never silently overwrite them.
|
|
||||||
_remindersTouched.value = true
|
|
||||||
maybePromptImportedReminderDefault(form)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,12 +266,26 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = reminderDefaults()
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
val reminders = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -349,60 +295,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot the four settings-default reminder flows into one value. */
|
|
||||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
|
||||||
* file often has none while the user still expects their configured default.
|
|
||||||
* Rather than silently deciding, raise a one-time offer to swap in the
|
|
||||||
* settings default — but only when there's a real choice: a default is
|
|
||||||
* configured and it isn't already exactly what the file carried.
|
|
||||||
*/
|
|
||||||
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val targetId = resolvedCalendarId.first()
|
|
||||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
|
||||||
// Bail if the form closed or became an edit while we resolved.
|
|
||||||
val current = _form.value ?: return@launch
|
|
||||||
if (_editTarget.value != null) return@launch
|
|
||||||
if (default.isEmpty() || default == current.reminders) return@launch
|
|
||||||
_importReminderPrompt.value = ImportReminderPrompt(
|
|
||||||
currentReminderCount = current.reminders.size,
|
|
||||||
defaultReminders = default,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accept the import prompt: replace the file's reminders with the default. */
|
|
||||||
fun applyImportedReminderDefault() {
|
|
||||||
val prompt = _importReminderPrompt.value ?: return
|
|
||||||
_importReminderPrompt.value = null
|
|
||||||
// Already frozen as touched by openImported; this just swaps the values.
|
|
||||||
update { it.copy(reminders = prompt.defaultReminders) }
|
|
||||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decline the import prompt: keep the file's own reminders untouched. */
|
|
||||||
fun dismissImportedReminderPrompt() {
|
|
||||||
_importReminderPrompt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. An external
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
* form is open, so user edits survive configuration changes.
|
||||||
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
|
||||||
* form loads the event's real times instead of the epoch (mirrors the
|
|
||||||
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
|
||||||
* survive configuration changes.
|
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -416,12 +312,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||||
?: detail.instance.start.toEpochMilliseconds()
|
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
|
||||||
?: detail.instance.end.toEpochMilliseconds()
|
|
||||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -437,7 +329,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
_importReminderPrompt.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -548,15 +439,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
|
||||||
// be updated in place) and is inherently whole-series: an occurrence
|
|
||||||
// can't live in a different calendar than its series, so a move skips the
|
|
||||||
// scope dialog even for a recurring event.
|
|
||||||
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
|
||||||
// Managed events are a yearly series whose editable fields (reminders,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// which would split the series into an exception the sync then reverts.
|
||||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -611,17 +497,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
} else if (form.calendarId != target.original.calendarId) {
|
|
||||||
// Move to the picked calendar (copy+delete), carrying any
|
|
||||||
// field edits made in the same save. The target becomes the
|
|
||||||
// last-used calendar, like a create does.
|
|
||||||
repository.moveEvent(
|
|
||||||
eventId = target.eventId,
|
|
||||||
targetCalendarId = requireNotNull(form.calendarId),
|
|
||||||
original = target.original,
|
|
||||||
updated = form,
|
|
||||||
)
|
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
|
||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
@@ -1175,9 +1174,10 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
SnoozeDurationPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
presets = SNOOZE_PRESETS,
|
predictiveBack = true,
|
||||||
|
options = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
<string name="event_edit_gone_title">Event deleted</string>
|
<string name="event_edit_gone_title">Event deleted</string>
|
||||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||||
|
|
||||||
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
|
||||||
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
|
||||||
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
|
||||||
<item quantity="one">This event was imported with %1$d reminder.</item>
|
|
||||||
<item quantity="other">This event was imported with %1$d reminders.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="import_reminder_prompt_apply">Apply default</string>
|
|
||||||
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
|
||||||
|
|
||||||
<!-- Event form — recurrence picker (v1.3) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
@@ -236,9 +226,6 @@
|
|||||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
<string name="reminder_action_snooze">Snooze</string>
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
<string name="reminder_action_dismiss">Dismiss</string>
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
|
||||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
|
||||||
<string name="reminder_day_yesterday">Yesterday</string>
|
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
|
|||||||
@@ -312,46 +312,6 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
|
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
|
||||||
val original = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
title = "Stand-up",
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
|
||||||
)
|
|
||||||
val updated = original.copy(calendarId = 3L)
|
|
||||||
|
|
||||||
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
|
|
||||||
|
|
||||||
assertThat(newId).isEqualTo(77L)
|
|
||||||
assertThat(fake.movedEvents).containsExactly(
|
|
||||||
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
writeError = WriteFailedException("insert moved event into calendar id=3")
|
|
||||||
}
|
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
|
||||||
val form = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
|
|
||||||
error("Expected WriteFailedException")
|
|
||||||
} catch (expected: WriteFailedException) {
|
|
||||||
assertThat(expected.message).contains("3")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class EventDetailMapperTest {
|
|||||||
accessLevel: Any? = null,
|
accessLevel: Any? = null,
|
||||||
timezone: String? = null,
|
timezone: String? = null,
|
||||||
selfStatus: Any? = null,
|
selfStatus: Any? = null,
|
||||||
duration: String? = null,
|
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -52,7 +51,6 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
EventDetailProjection.IDX_DURATION to duration,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -136,32 +134,12 @@ class EventDetailMapperTest {
|
|||||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
// A yearly birthday/anniversary anchored before the epoch has a
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
// no DTEND (they use DURATION), so the length comes from DURATION.
|
// no DTEND (they use DURATION), so it stays end == begin.
|
||||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
|
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `absent dtend takes its length from DURATION (issue #48)`() {
|
|
||||||
// A recurring series row has no DTEND. Opened without a named
|
|
||||||
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
|
|
||||||
// own times are what render, so DURATION has to supply the length or
|
|
||||||
// the event shows as zero-length (10:00–10:00).
|
|
||||||
val begin = 1_000_000_000L
|
|
||||||
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
|
|
||||||
assertThat(detail).isNotNull()
|
|
||||||
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `absent dtend and absent DURATION stays zero-length`() {
|
|
||||||
val begin = 1_000_000_000L
|
|
||||||
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
|
|
||||||
assertThat(detail).isNotNull()
|
|
||||||
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -220,117 +220,6 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- buildOccurrenceCancelValues ("delete only this event") ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `occurrence cancel anchors a single instance and cancels only it`() {
|
|
||||||
val values = buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis = 1_700_000_000_000L,
|
|
||||||
dtStartMillis = 1_700_000_000_000L,
|
|
||||||
duration = "P3600S",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
|
||||||
.isEqualTo(1_700_000_000_000L)
|
|
||||||
// DTSTART + DURATION make the provider derive a single instance and drop
|
|
||||||
// the inherited RRULE, so only this occurrence is cancelled — not the
|
|
||||||
// whole series (#47). DTEND is never sent (the provider rejects it).
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
|
||||||
assertThat(values[CalendarContract.Events.STATUS])
|
|
||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
|
|
||||||
val values = buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis = 1_700_000_000_000L,
|
|
||||||
dtStartMillis = 1_700_000_000_000L,
|
|
||||||
duration = "P1D",
|
|
||||||
timezone = "UTC",
|
|
||||||
allDay = 1,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
|
||||||
assertThat(values[CalendarContract.Events.STATUS])
|
|
||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
|
|
||||||
// 2026-07-15T08:00:00Z.
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = null,
|
|
||||||
occurrenceMillis = 1_784_102_400_000L,
|
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
|
||||||
duration = "PT1H",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
|
||||||
// The whole time/recurrence set rides along: an EXDATE-only update is not
|
|
||||||
// treated as a recurrence change, so the provider would leave the expanded
|
|
||||||
// instances (and the occurrence) in place. DTSTART alone is worse — it
|
|
||||||
// makes the provider recompute lastDate as a single instance and collapse
|
|
||||||
// the series to its first occurrence.
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `exdate drop appends to an existing exdate list`() {
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = "20260714T080000Z",
|
|
||||||
occurrenceMillis = 1_784_102_400_000L,
|
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
|
||||||
duration = "PT1H",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.EXDATE])
|
|
||||||
.isEqualTo("20260714T080000Z,20260715T080000Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `exdate drop folds away a repeated occurrence`() {
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = "20260715T080000Z",
|
|
||||||
occurrenceMillis = 1_784_102_400_000L,
|
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
|
||||||
duration = "PT1H",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `all-day exdate drop uses the date-only form`() {
|
|
||||||
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = null,
|
|
||||||
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
|
||||||
dtStartMillis = 1_783_900_800_000L,
|
|
||||||
rrule = "FREQ=YEARLY",
|
|
||||||
duration = "P1D",
|
|
||||||
timezone = "UTC",
|
|
||||||
allDay = 1,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
|
|
||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -409,114 +298,4 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
|
||||||
|
|
||||||
private fun masterSnapshot(
|
|
||||||
isAllDay: Boolean = false,
|
|
||||||
dtStartMillis: Long = 1_781_164_800_000L,
|
|
||||||
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
|
|
||||||
duration: String? = null,
|
|
||||||
rrule: String? = null,
|
|
||||||
rdate: String? = null,
|
|
||||||
exdate: String? = null,
|
|
||||||
) = MasterEventSnapshot(
|
|
||||||
title = "Standup",
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
dtStartMillis = dtStartMillis,
|
|
||||||
dtEndMillis = dtEndMillis,
|
|
||||||
duration = duration,
|
|
||||||
rrule = rrule,
|
|
||||||
rdate = rdate,
|
|
||||||
exdate = exdate,
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
|
||||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
|
||||||
status = CalendarContract.Events.STATUS_CONFIRMED,
|
|
||||||
location = "Room 1",
|
|
||||||
description = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
|
|
||||||
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
|
|
||||||
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
|
|
||||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
|
||||||
// Empty description clears explicitly; a raw/keyed colour is never copied
|
|
||||||
// (may be invalid on the target account — the copy inherits its colour).
|
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
|
|
||||||
val values = buildMovedMasterValues(
|
|
||||||
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
|
|
||||||
targetCalendarId = 9L,
|
|
||||||
uid = "u@calendula",
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
|
||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
|
|
||||||
// Recurring rows never carry DTEND (the provider's invariant).
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moved series without a stored duration derives it from DTEND`() {
|
|
||||||
val values = buildMovedMasterValues(
|
|
||||||
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
|
|
||||||
targetCalendarId = 9L,
|
|
||||||
uid = "u@calendula",
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
|
|
||||||
|
|
||||||
private fun exceptionSnapshot(
|
|
||||||
isCancelled: Boolean = false,
|
|
||||||
duration: String? = null,
|
|
||||||
// Occurrence starts one hour into the day and runs 30 minutes.
|
|
||||||
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
|
|
||||||
) = ExceptionRowSnapshot(
|
|
||||||
exceptionEventId = 42L,
|
|
||||||
originalInstanceMillis = 1_781_164_800_000L,
|
|
||||||
isCancelled = isCancelled,
|
|
||||||
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
|
|
||||||
title = "Moved occurrence",
|
|
||||||
isAllDay = false,
|
|
||||||
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
|
|
||||||
dtEndMillis = dtEndMillis,
|
|
||||||
duration = duration,
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
|
||||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
|
||||||
location = "",
|
|
||||||
description = "Notes",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
|
|
||||||
val values = buildCopiedExceptionValues(exceptionSnapshot())
|
|
||||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
|
||||||
.isEqualTo(1_781_164_800_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
|
|
||||||
// 30-minute occurrence derived from DTEND, written as DURATION only.
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `copied modified occurrence keeps a stored duration verbatim`() {
|
|
||||||
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
val insertedForms = mutableListOf<EventForm>()
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||||
data class MovedEvent(
|
|
||||||
val eventId: Long,
|
|
||||||
val targetCalendarId: Long,
|
|
||||||
val original: EventForm,
|
|
||||||
val updated: EventForm,
|
|
||||||
)
|
|
||||||
val movedEvents = mutableListOf<MovedEvent>()
|
|
||||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
@@ -123,19 +116,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
allDayReminderTimes += allDayReminderTimeMinutes
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun moveEvent(
|
|
||||||
eventId: Long,
|
|
||||||
targetCalendarId: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
writeError?.let { throw it }
|
|
||||||
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
|
|
||||||
allDayReminderTimes += allDayReminderTimeMinutes
|
|
||||||
return nextInsertId
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -19,110 +18,41 @@ class ReminderTimeTextTest {
|
|||||||
private fun utcMidnight(date: LocalDate): Long =
|
private fun utcMidnight(date: LocalDate): Long =
|
||||||
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
|
|
||||||
private fun text(
|
|
||||||
beginMillis: Long,
|
|
||||||
endMillis: Long,
|
|
||||||
isAllDay: Boolean = false,
|
|
||||||
zone: ZoneId = berlin,
|
|
||||||
locale: Locale = Locale.GERMANY,
|
|
||||||
is24Hour: Boolean = true,
|
|
||||||
today: LocalDate? = null,
|
|
||||||
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
|
|
||||||
): String = reminderTimeText(
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
zone = zone,
|
|
||||||
locale = locale,
|
|
||||||
is24Hour = is24Hour,
|
|
||||||
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
|
||||||
tomorrowLabel = "Tomorrow",
|
|
||||||
yesterdayLabel = "Yesterday",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event today shows just the time range`() {
|
fun `timed event on one day shows just the time range`() {
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `12-hour preference renders an am-pm time range`() {
|
fun `12-hour preference renders an am-pm time range`() {
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
locale = Locale.US,
|
locale = Locale.US,
|
||||||
is24Hour = false,
|
is24Hour = false,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tomorrow's event is prefixed with the tomorrow label`() {
|
|
||||||
val text = text(
|
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
|
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `yesterday's event is prefixed with the yesterday label`() {
|
|
||||||
val text = text(
|
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
|
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an event later this week is prefixed with the short weekday`() {
|
|
||||||
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
|
|
||||||
val text = text(
|
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("So., 09:30 – 10:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the week boundary honours the week-start setting`() {
|
|
||||||
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
|
|
||||||
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
|
|
||||||
// and must render as a date — the opposite of the Monday-start case above.
|
|
||||||
val text = text(
|
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
firstDayOfWeek = DayOfWeek.SUNDAY,
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an event next week falls back to the exact date, not a weekday`() {
|
|
||||||
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
|
|
||||||
val text = text(
|
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
|
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event crossing midnight includes both dates`() {
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).contains("11.06.2026")
|
assertThat(text).contains("11.06.2026")
|
||||||
assertThat(text).contains("12.06.2026")
|
assertThat(text).contains("12.06.2026")
|
||||||
@@ -131,35 +61,28 @@ class ReminderTimeTextTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day single day shows one date, read in UTC`() {
|
fun `all-day single day shows one date, read in UTC`() {
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
// Zone must not matter for all-day events: UTC midnight is
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
// 02:00 in Berlin — naive local reading would shift the day.
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
today = LocalDate.of(2026, 6, 11),
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
|
|
||||||
val text = text(
|
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
|
||||||
isAllDay = true,
|
|
||||||
today = LocalDate.of(2026, 6, 11),
|
|
||||||
)
|
|
||||||
assertThat(text).isEqualTo("12.06.2026")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
today = LocalDate.of(2026, 6, 11),
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
}
|
}
|
||||||
@@ -167,11 +90,13 @@ class ReminderTimeTextTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `degenerate all-day range never renders an inverted span`() {
|
fun `degenerate all-day range never renders an inverted span`() {
|
||||||
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||||
val text = text(
|
val text = reminderTimeText(
|
||||||
beginMillis = day,
|
beginMillis = day,
|
||||||
endMillis = day,
|
endMillis = day,
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
today = LocalDate.of(2026, 6, 11),
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import org.junit.jupiter.api.AfterEach
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.io.TempDir
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Focuses on the save-time branch that decides between an in-place update and a
|
|
||||||
* calendar *move* (copy+delete). The provider-level move itself is verified
|
|
||||||
* on-device; here the fake records which repository call the ViewModel chose.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class EventEditViewModelTest {
|
|
||||||
|
|
||||||
private val dispatcher = UnconfinedTestDispatcher()
|
|
||||||
|
|
||||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
|
||||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
|
||||||
|
|
||||||
private val beginMillis = 1_781_164_800_000L
|
|
||||||
private val endMillis = beginMillis + 3_600_000L
|
|
||||||
|
|
||||||
private fun cal(id: Long): CalendarSource = CalendarSource(
|
|
||||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
|
||||||
instance = EventInstance(
|
|
||||||
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
|
||||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
|
||||||
end = Instant.fromEpochMilliseconds(endMillis),
|
|
||||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
|
||||||
),
|
|
||||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
|
|
||||||
// last-used calendar) completes under advanceUntilIdle instead of on a real
|
|
||||||
// IO thread the virtual clock can't observe.
|
|
||||||
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
|
|
||||||
PreferenceDataStoreFactory.create(
|
|
||||||
scope = CoroutineScope(dispatcher),
|
|
||||||
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
|
|
||||||
PreferenceDataStoreFactory.create(
|
|
||||||
scope = CoroutineScope(dispatcher),
|
|
||||||
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun viewModel(
|
|
||||||
tempDir: Path,
|
|
||||||
fake: FakeCalendarDataSource,
|
|
||||||
): EventEditViewModel {
|
|
||||||
val p = prefs(tempDir)
|
|
||||||
val s = settings(tempDir)
|
|
||||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
|
||||||
return EventEditViewModel(repo, p, s, dispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
|
||||||
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the calendar routes the save through a move, not an update`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
calendarsResult = listOf(cal(1L), cal(2L))
|
|
||||||
eventDetailResult = { detail(calendarId = 1L) }
|
|
||||||
nextInsertId = 99L
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
val job = activate(vm)
|
|
||||||
|
|
||||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
|
||||||
vm.setCalendar(2L)
|
|
||||||
vm.save()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(fake.movedEvents).hasSize(1)
|
|
||||||
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
|
|
||||||
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
|
|
||||||
assertThat(fake.updatedEvents).isEmpty()
|
|
||||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
calendarsResult = listOf(cal(1L), cal(2L))
|
|
||||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
val job = activate(vm)
|
|
||||||
|
|
||||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
|
||||||
vm.setCalendar(2L)
|
|
||||||
vm.save()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// No AwaitingScope park: a move is inherently whole-series.
|
|
||||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
|
||||||
assertThat(fake.movedEvents).hasSize(1)
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `editing a recurring event without moving still asks for the scope`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
calendarsResult = listOf(cal(1L), cal(2L))
|
|
||||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
val job = activate(vm)
|
|
||||||
|
|
||||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
|
||||||
vm.setTitle("Renamed")
|
|
||||||
vm.save()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
|
|
||||||
assertThat(fake.movedEvents).isEmpty()
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
### Fixed
|
|
||||||
- Deleting one occurrence of a repeating event no longer breaks the series.
|
|
||||||
Choosing "This event" when deleting an occurrence of a recurring event could
|
|
||||||
wipe out every *other* occurrence while leaving the one you deleted behind as
|
|
||||||
a stale, still-tappable ghost — and deleting it again brought the series back.
|
|
||||||
A single-occurrence delete now removes exactly that occurrence and leaves the
|
|
||||||
rest of the series untouched, and the deleted occurrence disappears from the
|
|
||||||
grid straight away. This holds on every kind of calendar, including the
|
|
||||||
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
|
||||||
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
|
||||||
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
|
||||||
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
|
||||||
it also answers the "open this event" one, so tapping an existing event in a
|
|
||||||
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
|
||||||
Thanks to @bushrang3r for the report ([#48]).
|
|
||||||
- Events created from other apps get your default reminder. An event handed over
|
|
||||||
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
|
||||||
widget's "+" — opened with no reminder at all, ignoring the default set in
|
|
||||||
Settings. It now starts with your default reminder, the same as an event you
|
|
||||||
create in Calendula. An event opened from an `.ics` file is treated differently,
|
|
||||||
because the file has its own say: Calendula keeps whatever reminders it carries
|
|
||||||
(including none at all) and asks you once whether to apply your default instead
|
|
||||||
— it never quietly overrides the file. If you have no default set, it doesn't
|
|
||||||
ask ([#49]).
|
|
||||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
|
||||||
app (such as DAVx5), the event colour picker showed every colour the account
|
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
|
||||||
duplicates or near-identical shades. The picker now shows only visually
|
|
||||||
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
|
||||||
washed-out neutrals are folded away so no two swatches look alike. Picked
|
|
||||||
colours still sync exactly as before, and calendars with hand-picked
|
|
||||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
|
||||||
([#22]).
|
|
||||||
|
|
||||||
Submodule floret-kit updated: 2124227a7f...78c5fd1fd4
Reference in New Issue
Block a user