Compare commits

..
Author SHA1 Message Date
makiolaj 46ca2db63a Month widget: count Glance's real view ids per Android version, test the budget on device (#372) 2026-10-21 18:44:14 +02:00
makiolaj a87ebc33b6 Month widget: tap a day anywhere opens it, fit busy months into Glance's view limit (#386, #372) 2026-10-21 18:18:01 +02:00
makiolaj 3471447ab6 Pin floret-kit to main (glance refresh helpers, #18) 2026-10-21 17:57:04 +02:00
makiolaj b206e0e6a5 Bump floret-kit onto current main 2026-10-21 17:48:24 +02:00
makiolaj 35f5dc1666 Fix widget onUpdate crash, skip provider echoes, per-widget redraws (#420, #229) 2026-10-21 17:36:30 +02:00
makiolaj 225901ceee Share widget loads across sizes, redraw on own changes only (#420, #229) 2026-10-21 17:02:43 +02:00
makiolaj 77e103c66c Move widgets onto floret-kit glance, reload on every redraw (#420, #229) 2026-10-21 15:26:05 +02:00
dbd5ce6700 Release v2.23.0 (#433)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Renovate / renovate (push) Successful in 1m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m17s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m31s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Stable 2.23.0 after four betas. It adds long-press select, move, resize and drafting in the timeline, tap-to-call phone numbers, formatted HTML descriptions, scrolling back in the agenda, events from shared text, the option to open the editor on tap, the black theme, Liberapay and more haptics. It also fixes the daylight saving and recurring-move bugs. CHANGELOG.md has the full list, and every store locale has its own What's New.

I merged main into the release branch to pick up Liberapay (#430) and Weblate #421. The Czech and Japanese string conflicts were lines added at the end of each file on both sides, so I kept main's.

Closes #116, closes #141, closes #153, closes #178, closes #181, closes #271, closes #306, closes #339, closes #367, closes #387, closes #413

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Tritra002 <tritratrulala002@outlook.com>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/433
2026-10-21 14:10:22 +02:00
Jean-Luc Makiola 068a96b228 Translations update from Weblate (#421)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m56s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/421
2026-10-21 13:46:56 +02:00
James Trivette 7c522c5903 Translated using Weblate (Czech)
Currently translated at 100.0% (656 of 656 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-10-21 08:16:14 +00:00
Sup99999 6ef516fe4d Translated using Weblate (Arabic)
Currently translated at 100.0% (651 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-10-21 02:38:13 +00:00
mirai yamada 9a7d65be6b Translated using Weblate (Japanese)
Currently translated at 17.6% (115 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-21 02:38:13 +00:00
mirai yamada c2aae1629f Translated using Weblate (Japanese)
Currently translated at 17.3% (113 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-21 02:38:13 +00:00
Weblate a697803e29 Translated using Weblate (Japanese)
Currently translated at 17.3% (113 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-21 02:38:13 +00:00
Weblate f4b6cb3acb Translated using Weblate (Japanese)
Currently translated at 16.4% (107 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-21 02:38:13 +00:00
mirai yamada c3f3eb83f2 Translated using Weblate (Japanese)
Currently translated at 16.4% (107 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-21 02:38:13 +00:00
Jean-Luc Makiolaandmakiolaj 5d10f773ca Add Liberapay alongside Ko-fi for donations (#430)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m1s
Adds Liberapay as a second donation option next to Ko-fi.

- Settings: "Support development" now opens a full-screen picker with Ko-fi (one-off tips) and Liberapay (recurring donations) instead of linking straight to Ko-fi.
- README: Liberapay badge next to the Ko-fi badge, plus a short Support section.

No issue for this one.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/430
2026-10-20 11:09:52 +02:00
Jean-Luc Makiola b2fcc55018 Translations update from Weblate (#391)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m33s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/391
2026-10-15 16:22:27 +02:00
mirai yamada 10414fcf05 Translated using Weblate (Japanese)
Currently translated at 13.2% (86 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-10-15 12:55:25 +00:00
Dmitri Gabinski 49f604bdc2 Translated using Weblate (Belarusian)
Currently translated at 92.6% (603 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-10-13 12:41:03 +00:00
Dmitri Gabinski 56763f58da Translated using Weblate (Russian)
Currently translated at 39.1% (255 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-10-13 12:41:02 +00:00
Dmitri Gabinski af34f014a7 Translated using Weblate (Belarusian)
Currently translated at 90.6% (590 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-10-13 10:01:47 +00:00
Dmitri Gabinski 3280191309 Translated using Weblate (Belarusian)
Currently translated at 90.3% (588 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-10-13 09:50:18 +00:00
Jean-Luc Makiola 76cdc1c535 Translations update from Weblate (#356)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 36s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m5s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/356
2026-10-12 21:51:27 +02:00
Sup99999 68c0a262fa Translated using Weblate (Arabic)
Currently translated at 100.0% (651 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-10-10 21:10:40 +00:00
makiolaj 39e13e86d2 Translated using Weblate (French)
Currently translated at 90.4% (589 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-10-10 21:10:40 +00:00
James Trivette 1cc7f40f7e Translated using Weblate (Czech)
Currently translated at 98.7% (643 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-10-10 21:10:40 +00:00
Dmitri Gabinski 8a15325fdc Translated using Weblate (Belarusian)
Currently translated at 87.0% (567 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-10-10 21:10:40 +00:00
MaxrregMustermann 7f977971ed Translated using Weblate (German)
Currently translated at 98.0% (638 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-10-10 21:10:40 +00:00
N Donkersloot 632155c016 Translated using Weblate (Dutch)
Currently translated at 100.0% (651 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/nl/
2026-10-10 21:10:40 +00:00
N Donkersloot 8834884d4e Translated using Weblate (Dutch)
Currently translated at 97.0% (632 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/nl/
2026-10-10 21:10:40 +00:00
VinCap 236372d740 Translated using Weblate (Italian)
Currently translated at 99.5% (648 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-10-10 21:10:40 +00:00
Sup99999 678a526cb9 Translated using Weblate (Arabic)
Currently translated at 100.0% (651 of 651 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-10-10 21:10:40 +00:00
Weblate 84fd51baf6 Translated using Weblate (Arabic)
Currently translated at 100.0% (649 of 649 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-10-10 21:10:40 +00:00
Sup99999 450b8b0107 Translated using Weblate (Arabic)
Currently translated at 100.0% (649 of 649 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-10-10 21:10:40 +00:00
Weblate 8078fb4f10 Translated using Weblate (Czech)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-10-10 21:10:40 +00:00
Jean-Luc Makiolaandmakiolaj 46b88cc03e ci: route jobs to the gradle and light runners (#385)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m34s
Moves Gradle jobs to the dedicated `gradle` runner (capacity 1, 8g per job) and the cheap gate/lint jobs to `light`. Two runners used to share the `docker` label, so two Gradle builds could run at once and the VPS ran out of memory. Only `runs-on` lines change.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/385
2026-10-10 12:02:34 +02:00
216 changed files with 5876 additions and 7245 deletions
+16 -1
View File
@@ -7,8 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.23.0] — 2026-10-21
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. The bar shows the new times while you move or resize. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
@@ -16,12 +18,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
- **Donate through Liberapay.** Settings → Support development now offers Liberapay for recurring donations next to Ko-fi for one-off tips.
- **More haptic feedback.** Swiping between days in the split month pane ticks, and dropping a moved or resized event gives a short buzz ([#116]).
### Changed
- **The editor says Create for a new event.** New events, duplicates, shared text, imported .ics files and long-press drafts show Create instead of Save, so a duplicate no longer looks like you're editing the original ([#141]).
### Fixed
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
- **Moving a recurring event to another calendar keeps the occurrences you changed.** Deleted occurrences used to come back after the move, and edited ones showed up twice. They now stay deleted or edited, and keep their colour.
- **Sharing an .ics file to Calendula opens the import,** even when the email app adds a subject line as text.
- **Rotating the phone no longer throws away an open import, duplicate or picker.**
- **The repeat-until date picker opens on today.** Near midnight it used to open on yesterday or tomorrow.
- **Tap ripples on week numbers, split-view day cells and the split-view handle stay inside their rounded shape** ([#413]).
- **The export calendar picker previews the back gesture** like the other full-screen pickers.
## [2.22.5] — 2026-10-07
@@ -1874,3 +1887,5 @@ automatically, with zero telemetry and no internet permission.
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
+9
View File
@@ -23,6 +23,8 @@ It works on top of the system calendar and has no network access.</p>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
&nbsp;
<a href="https://liberapay.com/Jean-Luc_Makiola/donate"><img src="https://liberapay.com/assets/widgets/donate.svg" alt="Donate using Liberapay" height="56"></a>
</p>
<p>
@@ -203,6 +205,13 @@ anywhere. Your events stay in Android's own calendar store.
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## Support
Calendula is free and has no ads. If you'd like to support its development,
you can tip once on [Ko-fi](https://ko-fi.com/jeanlucmakiola) or donate
regularly on [Liberapay](https://liberapay.com/Jean-Luc_Makiola/donate). Both
are linked in the app under *Settings → Support development*.
## License
[MIT](LICENSE), Jean-Luc Makiola, 2026
+6 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230004
versionName = "2.23.0-beta.4"
versionCode = 2230099
versionName = "2.23.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -175,9 +175,13 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Moving a synced series keeps its deleted and edited occurrences, although the
* moved row has no `_sync_id` for exception rows to attach to.
*/
@RunWith(AndroidJUnit4::class)
class MoveEventExceptionsTest {
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver = context.contentResolver
private val ds = AndroidCalendarDataSource(context)
private val zone = TimeZone.currentSystemDefault()
private val day = 86_400_000L
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
private val hour = 3_600_000L
/** "day offset:title" of every instance on [calendarId]. */
private fun occurrences(calendarId: Long): List<String> =
ds.instances(start - day, start + 10 * day)
.filter { it.calendarId == calendarId }
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
.sorted()
@Test
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
try {
val src = insertSyncedSeries(a)
ds.deleteOccurrence(src, start + 2 * day)
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
assertThat(occurrences(a)).isEqualTo(expected)
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
ds.moveEvent(src, b, form, form, 540)
assertThat(occurrences(a)).isEmpty()
assertThat(occurrences(b)).isEqualTo(expected)
} finally {
ds.deleteCalendar(a)
ds.deleteCalendar(b)
}
}
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
private fun insertSyncedSeries(calendarId: Long): Long {
val accountName = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
)!!.use { it.moveToFirst(); it.getString(0) }
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "series")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DURATION, "P3600S")
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
put(CalendarContract.Events._SYNC_ID, "move-test-series")
}
return ContentUris.parseId(resolver.insert(uri, values)!!)
}
}
@@ -0,0 +1,125 @@
package de.jeanlucmakiola.calendula.widget.month
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.ExperimentalGlanceApi
import androidx.glance.appwidget.compose
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate
import java.time.ZoneId
/**
* Renders the month widget for a packed month through Glance's own translator
* and counts what it inflates (#372): the view-id model in [WeekLayout] has to
* match what Glance really builds, or a busy month fails with "too many views".
*/
@OptIn(ExperimentalGlanceApi::class)
@RunWith(AndroidJUnit4::class)
class MonthWidgetViewBudgetTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private var calendarId = -1L
private fun Uri.asSyncAdapter(): Uri = buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
@Before
fun packTheMonth() {
val calendar = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
calendarId = ContentUris.parseId(requireNotNull(uri))
val zone = ZoneId.systemDefault()
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
val events = (0 until 49).flatMap { offset ->
val day = first.plusDays(offset.toLong())
(0 until EVENTS_PER_DAY).map { i ->
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "Busy $i")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
}
}
}
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
}
@After
fun removeCalendar() {
if (calendarId < 0) return
context.contentResolver.delete(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
null,
null,
)
}
@Test
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
val views = MonthWidget().compose(context, size = size)
var total = 0
var texts = 0
instrumentation.runOnMainSync {
val root = views.apply(context, FrameLayout(context))
fun walk(view: View) {
total += 1
if (view is TextView) texts += 1
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
walk(root)
}
Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET")
// The grid itself rendered, not Glance's error layout: every day number is there.
assertThat(texts).isAtLeast(35)
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
}
}
private companion object {
const val TAG = "MonthWidgetBudget"
const val ACCOUNT = "calendula-widget-budget-test"
const val EVENTS_PER_DAY = 6
const val GLANCE_VIEW_IDS = 499
}
}
+9 -7
View File
@@ -152,7 +152,7 @@
</intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -343,12 +343,13 @@
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync).
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
filter here; DATE_CHANGED is a free extra only, since Android 8+
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
Exported: the system broadcasts arrive from outside the app. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
@@ -365,6 +366,7 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
@@ -10,7 +10,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,8 @@ class CalendulaApp : Application() {
chooseIssueUrl = getString(R.string.report_issue_choose_url),
),
)
// Before anything that writes, so the widgets see every change (#420).
widgetEntryPoint().widgetRefreshCoordinator().start()
reconcileAutoBackup()
reconcileSpecialDates()
reconcileCalendarVisibility()
@@ -58,7 +61,7 @@ class CalendulaApp : Application() {
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
syncWidgetRollover(this@CalendulaApp)
}
}
@@ -6,6 +6,7 @@ import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.OpenableColumns
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,11 +30,15 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
import de.jeanlucmakiola.calendula.ui.ExternalRequests
import de.jeanlucmakiola.calendula.ui.InsertRequest
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
@@ -57,6 +62,8 @@ import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -65,9 +72,6 @@ import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -79,23 +83,12 @@ class MainActivity : AppCompatActivity() {
// picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
// A navigation a home-screen widget asked for (open a date / start a
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30) or shared text (#306).
// Consumed once by CalendarHost, which opens it in the create form for review.
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
private val requests = ExternalRequests(
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
)
// Whether such a form is still open. Only then does a recreation re-read the
// intent to bring it back; a saved or dismissed one stays closed.
@@ -105,11 +98,6 @@ class MainActivity : AppCompatActivity() {
// app that asked instead of leaving the calendar on top of it.
private var launchedForInsert = false
// 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
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -137,16 +125,19 @@ class MainActivity : AppCompatActivity() {
// backdrop and bar icons instead of the night qualifier's (#181).
val cachedTheme = WindowThemeCache.read(this)
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = when (cachedTheme) {
null, ThemeMode.SYSTEM -> systemDark
ThemeMode.LIGHT -> false
ThemeMode.DARK, ThemeMode.BLACK -> true
}
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
applyEdgeToEdge()
val appearance = MutableStateFlow(
Appearance(
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
dynamicColor = WindowThemeCache.readDynamicColor(this),
),
)
lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode ->
WindowThemeCache.write(this@MainActivity, mode)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
appearance.value = stored
}
}
// Android hands the intent that started the task back to onCreate every
@@ -162,29 +153,30 @@ class MainActivity : AppCompatActivity() {
// is what carries one through a recreation — the create form only while
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch()
requests.replayed = savedInstanceState != null
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requests.editKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull()
requests.nav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull()
requests.importUri = intent.importUriOrNull()
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requestedInsert != null
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requests.insert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requestedInsert != null)
?: (requests.insert != null)
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK, ThemeMode.BLACK -> true
}
// Seeded from the cache, so the first frame doesn't draw the default
// theme and palette before the stored choices arrive.
val shownAppearance by appearance.collectAsStateWithLifecycle()
val themeMode = shownAppearance.themeMode
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice.
@@ -222,8 +214,8 @@ class MainActivity : AppCompatActivity() {
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode == ThemeMode.BLACK,
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
pureBlack = themeMode.isPureBlack,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -236,21 +228,7 @@ class MainActivity : AppCompatActivity() {
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
requests = requests,
)
}
// A persistent corner marker so a debug build is never
@@ -310,14 +288,22 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
requests.replayed = false
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requests.importUri = it }
intent.createRequestOrNull()?.let {
requestedInsert = it
requests.insert = it
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
intent.editEventKeyOrNull()?.let { requests.editKey = it }
// The user moved on into the app, so closing the create form no longer
// hands control back to the app that started it.
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
requests.editKey != null
) {
launchedForInsert = false
}
}
/**
@@ -338,9 +324,10 @@ class MainActivity : AppCompatActivity() {
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
// Plain text is a create (#306), never a file to import.
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
.takeUnless { type == SHARED_TEXT_TYPE }
// Shared text is a create (#306); a text/plain file is imported only
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
Intent.ACTION_SEND -> sharedStreamOrNull()
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -394,7 +381,7 @@ class MainActivity : AppCompatActivity() {
*/
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
@@ -415,6 +402,23 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, ImportSource.InsertOpenEnded)
}
private fun Intent.sharedStreamOrNull(): Uri? =
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
private fun Uri.isCalendarFile(): Boolean {
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
if (resolvedType in CALENDAR_TYPES) return true
val name = when (scheme) {
"content" -> runCatching {
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()
else -> lastPathSegment
} ?: return false
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
}
/** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null
@@ -541,6 +545,8 @@ class MainActivity : AppCompatActivity() {
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val SHARED_TEXT_TYPE = "text/plain"
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
@@ -633,3 +639,6 @@ class MainActivity : AppCompatActivity() {
}
}
}
/** The theme choices the window needs before the settings screen's state loads. */
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
@@ -28,6 +26,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
@@ -133,8 +132,7 @@ class BackupWorker(
private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
if (!canPost) return
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
@@ -1,13 +1,12 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
@@ -15,7 +14,6 @@ import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
@@ -28,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
@@ -36,9 +33,11 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
@@ -76,21 +75,13 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
* Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
* picker shows it curated ([curatedForPicker]); matching a colour that came
* from outside the account runs against this full set, since those keys are
* all the calendar accepts.
*/
fun publishedEventColors(calendarId: Long): List<EventColorOption>
@@ -238,8 +229,10 @@ interface CalendarDataSource {
* 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
* `UID_2445` preserved), its reminders and editable guests copied, its
* exceptions turned into EXDATEs (modified ones also into standalone events,
* as the new row has no `_sync_id` to attach them to), 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].
@@ -323,8 +316,7 @@ class AndroidCalendarDataSource @Inject constructor(
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
context.hasPermission(Manifest.permission.READ_CALENDAR)
/**
* Attach any not-yet-registered observers to the provider, but only once the
@@ -358,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
)?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
}
/**
@@ -375,7 +367,20 @@ class AndroidCalendarDataSource @Inject constructor(
)
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
/**
* Insert an owned, visible, syncing calendar on the Calendula local account;
* [extra] adds the columns that tell the kinds apart. [what] names the write
* in the failure — never the calendar name, which can reach a crash report.
*/
private fun insertLocalCalendar(
displayName: String,
color: Int,
what: String,
extra: ContentValues.() -> Unit,
): Long {
val name = displayName.trim()
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
@@ -391,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description)
extra()
}
val uri = resolver.insert(localCalendarsUri(), values)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
return ContentUris.parseId(uri)
}
@@ -460,33 +463,15 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim()
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
insertLocalCalendar(displayName, color, "create managed calendar") {
// The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type))
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows(
eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
)
return eventId
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
}
override fun applyManagedCalendarReminders(
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
queryManagedEvents(calendarId).forEach { event ->
// Managed events are all-day: DTSTART is a UTC midnight anchor.
reconcileReminders(
event.eventId,
managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
)
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query(
uri,
instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
"${CalendarContract.Events.DELETED} = 0",
null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<SearchCandidate> {
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
endMillis: Long,
soonestFirst: Boolean,
): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query(
uri,
instancesUri(beginMillis, endMillis),
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
eventUri(eventId),
EventDetailProjection.COLUMNS,
null, null, null,
)?.use { c ->
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
@@ -770,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor(
),
null,
)?.use { c ->
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?: emptyList()
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
c.mapAll {
val reader = CursorColumnReader(c)
val eventId = reader.getLong(EventExportProjection.IDX_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
reader.toIcsEvent(
c.mapRows {
val eventId = getLong(EventExportProjection.IDX_ID)
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
palette = colorPalette,
)
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
// (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate(
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
.atZone(ZoneOffset.UTC).toLocalDate(),
startDate = utcDate(event.start.toEpochMilliseconds()),
recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone),
)
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
} else {
minutes
}
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
// The event row is already in. A reminder that won't attach costs
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
insertReminderRow(eventId, providerMinutes)
}
return eventId
}
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
// back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues(
form,
uid = "${UUID.randomUUID()}@calendula",
uid = mintUid(),
times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply {
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
form.colorKey != null ->
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
) + eventColorColumns(form.colorKey, form.color)
// A new row has nothing to clear: unset columns inherit the
// calendar's colour, so only the key/raw values are written.
.filterValues { it != null }
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes)
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
encodedMinutes.forEach { insertReminderRow(eventId, it) }
}
/**
* Best effort: the event row is already in, so a reminder that won't attach
* costs that one alarm, not the event. Throwing would report an event that
* was created as failed — and an import retry would then skip it as a
* duplicate.
*/
private fun insertReminderRow(
eventId: Long,
minutes: Int,
method: Int = CalendarContract.Reminders.METHOD_ALERT,
) {
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, method)
}
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for event $eventId", e)
null
}
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
/**
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
}
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the
@@ -1067,16 +1009,17 @@ class AndroidCalendarDataSource @Inject constructor(
?: 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 uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
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")
val detached = mutableListOf<Long>()
try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId)
copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
// 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
@@ -1086,6 +1029,7 @@ class AndroidCalendarDataSource @Inject constructor(
// 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) }
detached.forEach(::rollBackDetached)
throw t
}
deleteEvent(eventId)
@@ -1094,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** 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),
eventUri(eventId),
MoveMasterProjection.COLUMNS,
null, null, null,
)?.use { c ->
@@ -1106,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
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) },
dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
@@ -1115,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
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) },
status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
),
@@ -1133,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
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")
}
}
while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
} ?: Unit
/**
@@ -1162,44 +1095,33 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* 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.
* Carry every exception of the series [fromEventId] over to the moved series
* [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
* never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
* occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
* event on [targetCalendarId], its id added to [detached] for rollback.
*/
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))
}
private fun copyExceptions(
fromEventId: Long,
toEventId: Long,
targetCalendarId: Long,
allDayReminderTimeMinutes: Int,
detached: MutableList<Long>,
) {
val exceptions = queryExceptionRows(fromEventId)
if (exceptions.isEmpty()) return
val zone = TimeZone.currentSystemDefault()
exceptions.filterNot { it.isCancelled }.forEach { ex ->
val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
.copy(calendarId = targetCalendarId, colorKey = null)
.toDetachedOccurrence()
detached += insertEvent(form, allDayReminderTimeMinutes)
}
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
}
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
@@ -1210,26 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
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) }
c.mapRows {
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),
exceptionEventId = getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
duration = getString(ExceptionProjection.IDX_DURATION),
)
}
} ?: emptyList()
@@ -1266,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key ->
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
eventUri(exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null,
)
@@ -1320,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
try {
updateEventRow(
eventId,
row.exdateValues(listOf(beginMillis)),
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
@@ -1420,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
val values = querySeriesRow(newEventId).timeColumns().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
@@ -1464,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
*/
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
val values = row.timeColumns().apply {
put(
CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
)
}
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
}
/** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
eventUri(eventId),
arrayOf(
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
@@ -1534,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
*/
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The time/recurrence set a series write carries together: the provider
* re-expands instances only from what the update itself holds.
*/
fun timeColumns(): ContentValues = ContentValues().apply {
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.RRULE, rrule)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
}
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate = exdate,
occurrences = occurrences,
dtStartMillis = dtStartMillis,
rrule = rrule,
rdate = rdate,
duration = duration,
timezone = timezone,
allDay = allDay,
)
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
@@ -1559,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(eventId.toString(), minutes.toString()),
)
}
(target - existing).forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
insertReminderRows(eventId, (target - existing).toList())
}
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
private fun EventAttendee.key(): String = email.trim().lowercase()
private fun EventAttendee.key(): String = emailKey(email)
/** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
@@ -1624,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
val relationship: Int,
val type: Int,
) {
val key: String get() = email.trim().lowercase()
val key: String get() = emailKey(email)
}
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
@@ -1689,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
)
}
}
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
}
private fun eventUri(eventId: Long): Uri =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
}
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
updateEventRow(eventId, values.toContentValues(), what)
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
@@ -1716,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
eventUri(eventId),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
@@ -1738,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
// 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,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
updateEventRow(
eventId,
row.exdateValues(listOf(beginMillis)),
"exdate occurrence event id=$eventId begin=$beginMillis",
)
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
@@ -1804,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
)?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
@@ -1812,19 +1689,10 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** Iterate every row and map; drops nulls. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) {
@@ -1840,6 +1708,9 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object {
const val TAG = "CalendarDataSource"
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
fun emailKey(email: String): String = email.trim().lowercase()
/**
* Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager.
@@ -18,6 +18,10 @@ interface CalendarRepository {
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail
/**
@@ -29,7 +33,7 @@ interface CalendarRepository {
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).
* exposes none (see [CalendarDataSource.publishedEventColors]).
*/
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -1,10 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -77,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit)
}
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
}
}
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set.
@@ -125,8 +139,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
),
switchedOffCalendarIds = invisibleCalendarIds() +
prefs.pendingDisabledCalendarIds.first(),
switchedOffCalendarIds = switchedOffCalendarIds(),
)
},
prefs.hiddenCalendarIds,
@@ -151,6 +164,10 @@ class CalendarRepositoryImpl @Inject constructor(
.filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id }
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
private suspend fun switchedOffCalendarIds(): Set<Long> =
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
private val calendarsLock = Mutex()
private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null
@@ -184,15 +201,13 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }
withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
override suspend fun createLocalCalendar(
displayName: String,
@@ -214,16 +229,15 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
// write lands (and switching one back on retires its entry).
prefs.removePendingDisabledCalendarIds(ids)
} else if (visible) {
val canWrite = dataSource.canWriteCalendars()
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the write
// lands, and switching one back on retires its entry. With read-only
// permission a switch-off still works, app-side, and the reconciler
// flushes it if WRITE_CALENDAR ever arrives.
if (canWrite || visible) {
prefs.removePendingDisabledCalendarIds(ids)
} else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids)
}
}
@@ -4,15 +4,15 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
@@ -97,9 +97,6 @@ class CalendarVisibilityReconciler @Inject constructor(
false
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/**
* Read-only view over a single row's columns by index. Lets the mappers work
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index)
}
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper"
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
// — 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)) {
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
// Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value.
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date.
val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val startDate = utcDate(begin)
val zone = ZoneId.systemDefault()
reminders.map {
it.copy(
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
reminders
}
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders,
status = status,
status = eventStatus(EventDetailProjection.IDX_STATUS),
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -372,48 +372,11 @@ 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
@@ -479,9 +442,24 @@ internal fun buildOccurrenceExdateValues(
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
)
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
internal fun buildOccurrencesExdateValues(
existingExdate: String?,
occurrences: List<Long>,
dtStartMillis: Long,
rrule: String?,
rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = mapOf(
CalendarContract.Events.EXDATE to
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
mergedExdate(exdate, millis, allDay != 0, timezone)
},
CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate,
@@ -631,7 +609,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half).
*/
private fun exdateStamps(exdate: String?): List<String> = exdate
internal fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
@@ -901,7 +879,7 @@ internal fun buildImportedEventValues(
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
// Unclamped: the writer renders DTEND as stored.
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
val startDate = utcDate(dtStart)
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status,
status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
@@ -112,11 +97,7 @@ internal fun exportExDates(
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdate
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
return exdateStamps(exdate)
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
// string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -1,7 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID,
@@ -251,40 +260,26 @@ internal object MoveMasterProjection {
/**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
* carry them over to 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.
* `DELETED = 0` so provider tombstones aren't carried over.
*/
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
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
}
internal object AttendeeProjection {
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
@@ -2,16 +2,15 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import de.jeanlucmakiola.floret.android.hasPermission
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
hasPermission(Manifest.permission.READ_CONTACTS)
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
annotation class ApplicationScope
@@ -24,8 +24,9 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -89,6 +90,7 @@ object DataProvideModule {
context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,18 +24,13 @@ import javax.inject.Singleton
*/
@Singleton
class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
store: DataStore<Preferences>,
) {
private val prefs = PrefStore(store)
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
// unrelated write would otherwise re-emit an identical set.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
/**
* Switch-offs the provider does not know about yet, because writing
@@ -48,24 +41,18 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it + ids }
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it + ids }
}
/**
* Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
}
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it - ids.toSet() }
}
/**
@@ -73,39 +60,28 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed.
*/
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
prefs[VISIBILITY_NOTICE_KEY]
}
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
suspend fun setVisibilityNoticePending(pending: Boolean) {
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
}
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
prefs[LAST_USED_CALENDAR_KEY]
}
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
suspend fun setLastUsedCalendarId(id: Long) {
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
private companion object {
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
}
}
private fun String?.parseIds(): Set<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -2,10 +2,8 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import javax.inject.Inject
import javax.inject.Singleton
@@ -20,17 +18,16 @@ import javax.inject.Singleton
*/
@Singleton
class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>,
store: DataStore<Preferences>,
) {
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun setLastScanMillis(millis: Long) {
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
}
}
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,8 @@ import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -44,27 +41,17 @@ class ReminderActionReceiver : BroadcastReceiver() {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
goAsync {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
}
}
ACTION_SHOW -> {
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
goAsync {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
}
}
@@ -4,20 +4,12 @@ import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 31–32.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
val uri = instancesUri(fromMillis, toMillis)
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
@@ -19,6 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
@@ -48,8 +47,7 @@ class ReminderNotifier @Inject constructor(
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
@@ -63,13 +61,9 @@ class ReminderNotifier @Inject constructor(
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/**
* Post [alert], unless its calendar is switched off. Returns whether the
* notification was put up, which the snooze re-show path uses to tell a
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
@@ -122,8 +116,6 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
// Handled either way — a retry hits the same revoked permission.
return true
}
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
@@ -2,19 +2,18 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
/**
* Re-scan when the provider changes, so a saved or deleted event re-arms the
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import de.jeanlucmakiola.floret.android.goAsync
import javax.inject.Inject
/**
@@ -29,14 +26,7 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
// exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
goAsync { scanner.scan() }
}
companion object {
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 31–32); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 31–32.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}
@@ -1,18 +1,20 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.floret.android.broadcastTicks
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -28,27 +30,16 @@ interface TodaySource {
/**
* Re-reads the date on every minute boundary while collected, and at once on the
* system's date, time and zone broadcasts, which can arrive late or not at all.
* One receiver and tick loop is shared by every collector.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : TodaySource {
override val today: Flow<LocalDate> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
override val today: Flow<LocalDate> = context
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
.transformLatest {
while (true) {
emit(currentDate())
@@ -56,8 +47,27 @@ class AndroidTodaySource @Inject constructor(
}
}
.distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
}
private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* Today as a hot flow seeded with [initial]. [onRollover] runs with the previous
* and the new date each time the day changes while collected, so a screen left
* open over midnight can move off yesterday (#373).
*/
fun Flow<LocalDate>.stateFollowingDays(
scope: CoroutineScope,
initial: LocalDate,
onRollover: (old: LocalDate, new: LocalDate) -> Unit,
): StateFlow<LocalDate> {
var last = initial
return onEach { date ->
val old = last
last = date
if (old != date) onRollover(old, date)
}.stateWhileSubscribed(scope, initial)
}
@@ -1,17 +1,16 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
import javax.inject.Inject
@@ -28,24 +27,16 @@ interface ZoneSource {
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
/** Follows the system's zone broadcast, one receiver shared by every collector. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : ZoneSource {
override val zone: Flow<TimeZone> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
.map { current() }
.distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -148,23 +148,21 @@ enum class EventFormProblem {
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) {
val pinned = if (instance.isAllDay) {
null
} else {
eventTimezone
?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
?.let { id -> zoneOrNull(id)?.let { id to it } }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) {
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
} else {
begin.toFormTime(formZone) to finish.toFormTime(formZone)
}
@@ -180,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability,
accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its
// calendar's colour.
@@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
rowEnd = instance.end,
)
/**
* Form times for an all-day span stored as UTC midnights with an exclusive
* end: the last covered day (never before [start]), with placeholder
* wall-clock times kept in case the user switches the event to timed.
*/
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
}
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an
@@ -243,8 +254,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
*/
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
return pinned.id != target.id
return resolvedZone(target).id != target.id
}
/**
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.plus
@@ -101,19 +101,13 @@ object EventSearch {
val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
.thenComparator { a, b ->
// What is left reads forwards; what is behind us reads backwards
// from today, nearest first.
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
// What is left reads forwards; what is behind us reads backwards from
// today, nearest first.
val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
past.sortedByDescending { it.event.dayStart(zone) }
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
return SearchResults(
months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit },
@@ -126,33 +120,18 @@ object EventSearch {
zone: TimeZone,
todayStart: Instant,
): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
return dated
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
.entries
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
when {
it.key == currentKey -> 0
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
val (behind, ahead) = groups.keys.partition { it < current }
return (ahead.sorted() + behind.sortedDescending()).map { month ->
SearchMonth(
year = month.year,
monthNumber = month.month.number,
hits = groups.getValue(month).map { it.hit },
)
.map { (key, entries) ->
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
}
}
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match(
candidate: SearchCandidate,
tokens: List<String>,
@@ -17,12 +17,15 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/**
* The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
timezone?.let(::zoneOrNull) ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
@@ -131,6 +131,10 @@ private class HtmlRenderer(private val html: String) {
text("<")
return lt + 1
}
if (!closing && isAutolink(html.substring(nameStart, end))) {
text(html.substring(lt, end + 1))
return end + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
@@ -149,6 +153,11 @@ private class HtmlRenderer(private val html: String) {
return end + 1
}
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
private fun isAutolink(inner: String): Boolean =
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
@@ -1,8 +1,5 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
@@ -35,17 +32,11 @@ fun buildInsertEventForm(
now: Instant,
): EventForm {
val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date
val endDate = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
allDayFormTimes(startDate, endExclusive)
} else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
@@ -62,7 +53,7 @@ fun buildInsertEventForm(
description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include.
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
rrule = rrule.normalizedRrule(),
)
}
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null
}
val freq = when (parts["FREQ"]?.uppercase()) {
"DAILY" -> RecurrenceFreq.Daily
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
?: return null
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
when (val e = end) {
RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
.toInstant(zone)
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
append(";UNTIL=")
append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
}
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
}
}
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
internal class RrulePart(val raw: String) {
private val eq = raw.indexOf('=')
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
}
/**
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
* with a malformed one (reject, repair or skip) is the caller's call.
*/
internal fun rruleParts(rule: String): List<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT"
}
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
return (kept + untilPart).joinToString(";")
}
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked.
*/
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
val date = LocalDate(
raw.substring(0, 4).toInt(),
raw.substring(4, 6).toInt(),
raw.substring(6, 8).toInt(),
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
val date = parseBasicDate(raw) ?: return null
if (raw.length < 15 || raw[8] != 'T') return date
return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
}
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
val parts = rruleParts(rrule)
if (parts.isEmpty()) return null
var freq = false
var weekly = false
var byDay = false
val rebuilt = parts.map { part ->
val eq = part.indexOf('=')
if (eq <= 0) return null
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
val value = part.value.trim()
when (part.key) {
"FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true)
part
part.raw
}
"BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
}
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
else -> return null
}
}
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
if (!freq || (byDay && !weekly)) return null
return prefix + rebuilt.joinToString(";")
}
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440
/**
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
dtEnd: IcsDateTime?,
duration: String?,
): Instant {
val explicit = dtEnd?.instant ?: duration?.let {
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
}
/**
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) ->
when {
token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
}
}
.distinct()
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
else -> line.params["TZID"]?.let(::zoneOrNull)
?: startZone
}
return ldt.toInstant(zone)
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -421,16 +415,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/**
* A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
}
/** Index of the matching `END:<component>` at/after [from], or list end. */
/**
* Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary.
*/
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
var i = from
while (i < end) {
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
(from until end).firstOrNull { i ->
val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
return i
}
i++
}
return end
}
line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
} ?: end
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from
while (i < lines.size) {
/** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
(from until lines.size).firstOrNull { i ->
val line = parseContentLine(lines[i])
if (line != null && line.name == "END" &&
line.value.trim().equals(component, true)
) {
return i
}
i++
}
return lines.size
}
line != null && line.name == "END" && line.value.trim().equals(component, true)
} ?: lines.size
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
"TENTATIVE" -> EventStatus.Tentative
"CANCELLED" -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
fun mapIcsStatus(value: String): EventStatus =
EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
?: EventStatus.Confirmed
}
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
)
@@ -64,7 +66,9 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
* rule at all — gives up entirely.
*/
fun sanitizeRrule(raw: String?): SanitizedRrule {
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
val rule = raw?.trim()
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
?.trim().orEmpty()
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
var freqSeen = false
@@ -79,13 +83,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
}
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
for (part in rruleParts(rule)) {
val key = part.key?.trim()?.uppercase().orEmpty()
val value = part.value.trim()
val kept = when {
key.isEmpty() || value.isEmpty() -> null
key == "FREQ" ->
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT")
add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}")
add("DTSTAMP:${dtStamp.toUtcBasic()}")
add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
val zone = zoneOrNull(event.zoneId)
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
} else {
// Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${event.end.toUtcBasic()}")
}
}
else -> {
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${event.end.toUtcBasic()}")
}
}
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
// A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
}
}
@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import de.jeanlucmakiola.calendula.domain.normalizedRrule
import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -23,10 +21,7 @@ import kotlinx.datetime.toLocalDateTime
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
} else {
this.start.toFormTime(zone) to this.end.toFormTime(zone)
}
@@ -40,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
rrule = recurrenceRule.normalizedRrule(),
)
}
@@ -4,8 +4,10 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn
@@ -28,10 +30,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned
@@ -39,6 +43,7 @@ import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
@@ -63,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -99,7 +104,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant
/**
@@ -116,29 +120,18 @@ import kotlin.time.Instant
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
* [requests] carries what notifications, widgets and other apps asked to
* open. A tapped reminder's occurrence opens the detail overlay exactly like an
* event tap; each request is cleared once acted on so a later recomposition
* can't re-open it. (A widget event tap instead arrives as
* [WidgetNavRequest.OpenEvent], which also roots the back stack in the widget's
* view.)
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CalendarHost(
requests: ExternalRequests,
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
@@ -163,7 +156,7 @@ fun CalendarHost(
val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
@@ -194,27 +187,17 @@ fun CalendarHost(
// it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
// placeholder — so the destination never loads a bogus id=0 on first frame.
// Like every overlay below, it draws its last key through the slide-out
// ([rememberHeld]) rather than a placeholder.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onOpenDetail: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
// A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey
onDetailKeyConsumed()
requests.detailKey = null
}
}
@@ -238,31 +221,17 @@ fun CalendarHost(
// calendar list rather than by preferences. Reached from both.
var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
// when the form is opened from a day/week grid tap; null from the FAB.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's start and end (#153) as epoch millis. Instants rather than
// minutes into a day, so the form opens at the times drawn even across a DST
// change (#387).
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
heldCreateDraftMillis = draftMillis
createDraftMillis = draftMillis
// Event form (v1.2 create), opened on a date, a tapped slot or a drafted block.
var createRequest by rememberSaveable(stateSaver = createRequestSaver) {
mutableStateOf<CreateRequest?>(null)
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
fun openCreate(dateIso: String, startMinutes: Int? = null, draftMillis: LongArray? = null) {
createRequest = CreateRequest(dateIso, startMinutes, draftMillis)
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date.toString(), startMinutes) }
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
}
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -272,25 +241,33 @@ fun CalendarHost(
// falling back to the auto-refreshing calendar is the only honest
// destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
// Saved as "open"; a recreation takes the form back from the ViewModel, which
// still holds the user's edits.
var importForm by rememberSaveable(
stateSaver = Saver<EventForm?, Boolean>(
save = { it != null },
restore = { open -> if (open) importViewModel.currentForm() else null },
),
) { 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) }
var importFormSource by rememberSaveable { mutableStateOf(ImportSource.File) }
// Bumped per open, so even an equal prefill starts a fresh form.
var importFormId by remember { mutableIntStateOf(0) }
var importFormId by rememberSaveable { mutableIntStateOf(0) }
// Whether [importForm] came from an external create request (#30, #306);
// closing it then hands control back to whoever asked.
var importFromRequest by rememberSaveable { mutableStateOf(false) }
// A second external request that arrived while a prefilled form was open:
// it waits behind that form's unsaved-changes prompt, then opens.
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
importFormSource = source
importForm = form
@@ -306,7 +283,7 @@ fun CalendarHost(
importForm = null
if (importFromRequest) {
importFromRequest = false
onExternalFormClosed()
requests.onExternalFormClosed()
}
}
}
@@ -314,7 +291,7 @@ fun CalendarHost(
// 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
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
var importForceMany by rememberSaveable { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
@@ -325,41 +302,64 @@ fun CalendarHost(
// what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) }
// A recreation re-reads the launch intent; when the import it carries came
// back with the saved state, that replay is the same work, not a new one.
fun isReplayOfRestoredImport(): Boolean {
val replay = requests.replayed && (importUri != null || importForm != null)
requests.replayed = false
return replay
}
val requestedImportUri = requests.importUri
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
if (isReplayOfRestoredImport()) {
requests.importUri = null
return@LaunchedEffect
}
importUri = requestedImportUri
importForceMany = false
importSession++
onImportConsumed()
requests.importUri = null
}
}
// An external ACTION_INSERT launch (another app/widget creating an event,
// issue #30) arrives already prefilled — open it in the same create form the
// single-event .ics path uses. [importForm] is the topmost overlay, so it
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
if (importForm == null) {
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true)
} else {
pendingInsert = requestedInsertForm to requestedInsertSource
val requestedInsert = requests.insert
LaunchedEffect(requestedInsert) {
if (requestedInsert != null) {
if (isReplayOfRestoredImport()) {
requests.insert = null
return@LaunchedEffect
}
onInsertConsumed()
if (importForm == null) {
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else {
pendingInsert = requestedInsert.form to requestedInsert.source
}
requests.insert = null
}
}
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
// top instead of underneath whatever the user had open. The create form,
// declared under the edit form, can stay for an edit to open over it.
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
showSettings = false
showSearch = false
showCalendars = false
showBackup = false
detailKey = null
editKey = null
if (!keepCreate) createRequest = null
importUri = null
importForm = null
importFromRequest = false
if (importFromRequest) {
importFromRequest = false
requests.onExternalFormClosed()
}
pendingInsert = null
importViewModel.reset()
}
@@ -367,21 +367,25 @@ fun CalendarHost(
// 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.
// without this it would open hidden underneath them. A saved edit just
// returns to the calendar.
val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
// An open edit form stays on screen, so it starts over here rather
// than once it has slid away.
if (editKey != null) editViewModel.reset()
dismissCoveringOverlays(keepCreate = true)
editKey = requestedEditKey
onEditKeyConsumed()
requests.editKey = null
}
}
// 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
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// the detail request. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
val widgetNavRequest = requests.nav
LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) {
navigationVersion++
@@ -392,23 +396,19 @@ fun CalendarHost(
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed()
requests.nav = null
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
requests.nav = null
}
is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date
@@ -416,26 +416,19 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
focus.navigateTo(localToday())
focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
requests.nav = null
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
if (createRequest != null) createViewModel.reset()
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
onWidgetNavConsumed()
openCreate(req.dateIso ?: currentDate().toString())
requests.nav = null
}
null -> {}
}
@@ -448,23 +441,28 @@ fun CalendarHost(
val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldEditKey = key
editKey = key
}
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
// A tap opens the edit form instead of the detail screen when the user chose
// so (#178) — only for events whose calendar takes writes. Both are read at
// tap time, so a tap before they've loaded still routes right.
val tapScope = rememberCoroutineScope()
val tapContext = LocalContext.current
val requireWriteOr = rememberWriteGateOr()
// A refused write upgrade is asked once; later taps just open the details.
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
val editAwaitingTap = rememberWriteAction(
onDenied = {
tapAwaitingWrite?.let { key ->
tapWriteRefused = true
detailKey = key
}
tapAwaitingWrite = null
},
) {
tapAwaitingWrite?.let { editKey = it }
tapAwaitingWrite = null
}
val onEventClick: (EventInstance) -> Unit = { event ->
tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
@@ -472,9 +470,9 @@ fun CalendarHost(
!toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event)
else -> requireWriteOr({ onEditEvent(event) }) {
tapWriteRefused = true
onOpenDetail(event)
else -> {
tapAwaitingWrite = event.occurrenceKey()
editAwaitingTap()
}
}
}
@@ -494,15 +492,10 @@ fun CalendarHost(
)
}
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null
// The view switch runs on a seekable transition, so a back gesture can scrub
@@ -696,11 +689,7 @@ fun CalendarHost(
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showSearch) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
@@ -708,23 +697,15 @@ fun CalendarHost(
)
}
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
activeKey?.let { key ->
val shownDetailKey = rememberHeld(detailKey)
SlideOverlay(visible = detailKey != null) {
shownDetailKey?.let { key ->
EventDetailScreen(
eventId = key[0],
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
onEdit = {
heldEditKey = key
editKey = key
},
onEdit = { editKey = key },
onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
@@ -738,32 +719,28 @@ fun CalendarHost(
}
// Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
viewModel = hiltViewModel(key = "create"),
)
val shownCreate = rememberHeld(createRequest)
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
shownCreate?.let { create ->
key(create) {
EventEditScreen(
initialDateIso = create.dateIso,
initialStartMinutes = create.startMinutes,
initialDraft = create.draftMillis,
onClose = { createRequest = null },
onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true },
visible = createRequest != null,
viewModel = createViewModel,
)
}
}
}
// Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
val shownEditKey = rememberHeld(editKey)
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
shownEditKey?.let { key ->
EventEditScreen(
initialDateIso = null,
editKey = key,
@@ -780,23 +757,16 @@ fun CalendarHost(
// below to show why, so open it.
onLoadFailed = {
editKey = null
if (detailKey == null) {
heldKey = key
detailKey = key
}
if (detailKey == null) detailKey = key
},
visible = editKey != null,
viewModel = hiltViewModel(key = "edit"),
viewModel = editViewModel,
)
}
}
// Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showSettings) {
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
@@ -854,11 +824,7 @@ fun CalendarHost(
// Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76).
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showCalendars) {
CalendarsScreen(
onBack = { showCalendars = false },
onOpenBackup = { showBackup = true },
@@ -867,11 +833,7 @@ fun CalendarHost(
}
// Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showBackup) {
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings
@@ -910,5 +872,48 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* An overlay's slide-in from the end edge, leaving from the predictive-back
* preview (#184): shrinking on from where the gesture let go instead of
* snapping back and sliding off.
*/
@Composable
private fun SlideOverlay(
visible: Boolean,
onHidden: () -> Unit = {},
content: @Composable AnimatedVisibilityScope.() -> Unit,
) {
val state = remember { MutableTransitionState(visible) }
state.targetState = visible
val hidden = state.isIdle && !state.currentState
LaunchedEffect(hidden) {
if (hidden) onHidden()
}
AnimatedVisibility(
visibleState = state,
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
exit = predictiveBackExit(),
content = content,
)
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
/**
* A create-form open: its date, plus the tapped slot's start (minutes from
* midnight) from a day/week grid tap, or a drafted block's start and end (#153)
* as epoch millis. Instants rather than minutes into a day, so the form opens at
* the times drawn even across a DST change (#387).
*/
private class CreateRequest(
val dateIso: String,
val startMinutes: Int?,
val draftMillis: LongArray?,
)
private val createRequestSaver = Saver<CreateRequest?, List<Any?>>(
save = { request -> request?.let { listOf(it.dateIso, it.startMinutes, it.draftMillis) } },
restore = { CreateRequest(it[0] as String, it[1] as Int?, it[2] as LongArray?) },
)
@@ -8,11 +8,10 @@ import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject
@@ -29,40 +28,35 @@ class CalendarHostViewModel @Inject constructor(
) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
@@ -74,9 +68,8 @@ class CalendarHostViewModel @Inject constructor(
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.ui
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
/**
* What a notification, widget, shortcut or another app asked the running app to
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
* new one into the running activity); CalendarHost acts on a request and clears
* it, so a later recomposition can't act on it twice.
*
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
* activity can hand control back to the app that asked.
*/
@Stable
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
var detailKey by mutableStateOf<LongArray?>(null)
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
var nav by mutableStateOf<WidgetNavRequest?>(null)
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
var importUri by mutableStateOf<Uri?>(null)
/**
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
* shared text (#306), opened in the create form for review.
*/
var insert by mutableStateOf<InsertRequest?>(null)
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
/** Whether these came from re-reading the launch intent on a recreation. */
var replayed = false
}
/** A prefilled create form from an external launch, with the source it came from. */
data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -20,11 +18,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
@@ -43,27 +41,12 @@ private sealed interface RootTarget {
@Composable
fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
mutableStateOf(context.canReadCalendar())
}
// A launch scan already covers the app coming up with the permission, so
@@ -74,9 +57,7 @@ fun RootScreen(
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
hasPermission = context.canReadCalendar()
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
@@ -146,19 +127,8 @@ fun RootScreen(
when (shown) {
RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost(
requests = requests,
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
onExternalFormClosed = onExternalFormClosed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
// Both halves of a transition read their position out of the live
// plan; the one the outgoing step was drawn with is already gone.
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/**
* How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
val daysInMonth = anchor.yearMonth.numberOfDays
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
locale: Locale,
monthAsSpan: Boolean = false,
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val javaStart = start.toJavaLocalDate()
val javaEnd = end.toJavaLocalDate()
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when {
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -9,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
@@ -30,23 +34,25 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import kotlinx.datetime.toJavaLocalDate
import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
@@ -177,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale),
formatTimeOfDay(label.start, zone, is24Hour, locale),
)
is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale),
formatTimeOfDay(label.end, zone, is24Hour, locale),
)
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
formatTime(label.end, zone, is24Hour, locale)
is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
formatTimeOfDay(label.end, zone, is24Hour, locale)
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val java = date.toJavaLocalDate()
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
}
/**
* The agenda's day-by-day list body: a sticky header per day, its events or a
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
* and the month view's split pane so both lists read the same.
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.agendaDays(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
@StringRes emptyText: Int,
onEmptyClick: (LocalDate) -> Unit,
dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(emptyText),
onClick = { onEmptyClick(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimmed(event, day.date),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -10,9 +10,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
@@ -66,11 +62,9 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.launch
@@ -379,7 +373,6 @@ internal fun AgendaContent(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
days: List<AgendaDay>,
@@ -400,11 +393,8 @@ private fun AgendaList(
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past
// days above. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there. The first run
// may restore a remembered day instead.
// A new anchor opens on its row from the first frame; keyed on the anchor
// the days arrived with, not the one requested. The first run may restore.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
@@ -444,39 +434,17 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(R.string.agenda_no_more_today),
onClick = { onOpenDay(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
agendaDays(
days = days,
today = today,
zone = zone,
// An anchored, event-less today (#35) — "nothing left today".
emptyText = R.string.agenda_no_more_today,
onEmptyClick = onOpenDay,
dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -1,19 +1,22 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,17 +27,13 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import javax.inject.Inject
@@ -66,9 +65,8 @@ class AgendaViewModel @Inject constructor(
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
@@ -79,9 +77,8 @@ class AgendaViewModel @Inject constructor(
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
@@ -101,21 +98,14 @@ class AgendaViewModel @Inject constructor(
var lastTopDate: LocalDate? = null
private set
private var lastToday = todayDate
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date && _window.value.anchor == old) {
_window.value = _window.value.copy(anchor = date)
}
private val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
@@ -156,9 +146,8 @@ class AgendaViewModel @Inject constructor(
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading,
)
@@ -174,15 +163,17 @@ class AgendaViewModel @Inject constructor(
}
/**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
* previous extension is still loading, so a scroll that keeps asking only
* moves one chunk at a time.
* Extend the window further back (#367), doubling what's loaded (at least
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
* re-queries only a few times. Ignored while the previous extension is
* still loading.
*/
fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value
if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
_window.value = window.copy(pastDays = window.pastDays + step)
}
fun rememberTopDay(date: LocalDate) {
@@ -244,9 +235,9 @@ class AgendaViewModel @Inject constructor(
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
internal const val MAX_PAST_STEP_DAYS = 365
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
val from = anchor.atStartOfDayIn(zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -54,13 +53,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
@@ -222,8 +225,8 @@ private fun BackupContent(
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary)
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
@@ -233,7 +236,7 @@ private fun BackupContent(
return
}
HintText(stringResource(R.string.calendars_backup_hint))
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
@@ -272,7 +275,7 @@ private fun BackupContent(
position = Position.Bottom,
onClick = onEditInterval,
)
HintText(backupStatusText(autoBackup.status))
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
}
@@ -309,12 +312,13 @@ private fun ExportCalendarPicker(
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
predictiveBack = true,
) {
HintText(stringResource(R.string.calendars_export_hint))
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName.orUnnamed(),
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
@@ -376,9 +380,7 @@ private fun backupIntervalLabel(minutes: Long): String {
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
val relative = relativeTimeAgo(status.lastRun)
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
@@ -12,27 +12,21 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -40,20 +34,14 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -84,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -93,12 +82,14 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
@@ -224,7 +215,7 @@ private fun CalendarsList(
visible = visible,
) {
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint))
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
// Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once;
@@ -245,24 +236,13 @@ private fun CalendarsList(
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) {
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
} else {
local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
},
CalendarManagerRow(
calendar = calendar,
last = index == local.lastIndex,
onSetVisible = onSetVisible,
onClick = { onEdit(calendar) },
)
}
@@ -291,8 +271,8 @@ private fun CalendarsList(
// Synced calendars — read-only, grouped by account. Each account is a
// collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch).
SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint))
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
synced
.groupByAccount()
.forEach { group ->
@@ -333,27 +313,10 @@ private fun CalendarsList(
// bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
CalendarManagerRow(
calendar = calendar,
last = index == ordered.lastIndex,
onSetVisible = onSetVisible,
)
}
}
@@ -692,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description.
*/
/**
* One calendar in a group: dimmed while switched off or not syncing, with the
* visibility switch only where it can change anything.
*/
@Composable
private fun EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
private fun CalendarManagerRow(
calendar: CalendarSource,
last: Boolean,
onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = calendarRowSummary(calendar),
position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarVisibilitySwitch(
calendarName = calendar.displayName.orUnnamed(),
enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
}
} else {
null
},
onClick = onClick,
)
}
@@ -841,33 +824,6 @@ private fun CalendarGroupMenu(
}
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/**
* Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission
@@ -9,16 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@@ -52,9 +51,8 @@ class CalendarsViewModel @Inject constructor(
repository.calendars()
.catch { emit(emptyList()) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
@@ -68,9 +66,8 @@ class CalendarsViewModel @Inject constructor(
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading,
)
@@ -84,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(),
)
@@ -103,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
val label = group.label.orUnnamed()
return if (!group.ambiguous) {
label
} else {
@@ -77,6 +77,8 @@ class EventSelection(
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
/** The event at the times an ongoing move or resize would give it, else null. */
val preview: () -> EventInstance? = { null },
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@@ -126,10 +128,12 @@ fun PublishSelection(
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
preview: () -> EventInstance? = { null },
) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate)
val currentPreview by rememberUpdatedState(preview)
val drafted = onCreate != null
val selection = remember(event, drafted) {
event?.let {
@@ -137,6 +141,7 @@ fun PublishSelection(
event = it,
dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null,
preview = { currentPreview() },
)
}
}
@@ -26,8 +26,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@@ -82,8 +80,7 @@ fun CalendarFabColumn(
)
}
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
val heldBar = rememberHeld(selection?.bar)
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
@@ -101,7 +98,7 @@ fun CalendarFabColumn(
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
heldBar?.invoke()
}
val label = stringResource(
when {
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/**
* The app's single "which calendar" selection list, shared by the event editor
@@ -114,17 +113,11 @@ private fun CalendarPickerGroup(
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = calendar.displayName.orUnnamed(),
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected,
checked = calendar.id == selectedId,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.R
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
/** A calendar or account name, or the "unnamed" label when it is blank. */
@Composable
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
@Composable
fun CalendarVisibilitySwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -18,12 +17,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
@@ -32,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
@@ -73,10 +73,13 @@ suspend fun CalendarRepository.deleteEvent(
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may read the calendar at all. */
fun Context.canReadCalendar(): Boolean =
hasPermission(Manifest.permission.READ_CALENDAR)
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
hasPermission(Manifest.permission.WRITE_CALENDAR)
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
@@ -98,33 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent(
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
* [action], which writes to the calendar, as a handler that asks for
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
* installs). A grant continues into [action], also when it returns after a
* rotation; a refusal runs [onDenied].
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val gate = rememberWriteGateOr()
return { action -> gate(action) {} }
}
/** [rememberWriteGate], running the fallback instead when the user refuses. */
@Composable
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
val currentAction by rememberUpdatedState(action)
val currentDenied by rememberUpdatedState(onDenied)
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
pending = null
if (granted) currentAction() else currentDenied()
}
return { action, fallback ->
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
launcher.launch(Manifest.permission.WRITE_CALENDAR)
return remember(context, launcher) {
{
if (context.canWriteCalendar()) {
currentAction()
} else {
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
}
@@ -6,8 +6,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -15,19 +15,18 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
@@ -43,6 +42,7 @@ class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
@@ -50,9 +50,8 @@ class EventActionsViewModel @Inject constructor(
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(),
)
@@ -66,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
return detail.toDuplicateForm(canModify, zoneSource.current())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -212,11 +212,21 @@ private fun rememberPickUpSurface(
}
}
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
/**
* A lift on pickup, a tick every time the drop target snaps to a new slot, and a
* confirm each time [drops] counts a real drop.
*/
@Composable
fun DragSnapHaptics(slot: Any?) {
fun DragSnapHaptics(slot: Any?, drops: Int) {
val haptics = LocalHapticFeedback.current
val previous = remember { arrayOfNulls<Any>(1) }
val dropsSeen = remember { intArrayOf(drops) }
LaunchedEffect(drops) {
if (drops != dropsSeen[0]) {
dropsSeen[0] = drops
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
}
}
LaunchedEffect(slot) {
val had = previous[0]
previous[0] = slot
@@ -9,7 +9,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -33,7 +32,6 @@ fun rememberFabSelection(
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title)
// What the FAB has been told for this selection, over what the coverage says.
@@ -44,10 +42,28 @@ fun rememberFabSelection(
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
// Each reads the selection at grant time, which may come after a rotation.
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
val onEditSelected = rememberWriteAction {
host.selection?.takeIf { it.create == null }?.let { selected ->
selected.dismiss()
currentEdit(selected.event)
}
}
val onDuplicateSelected = rememberWriteAction {
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
scope.launch {
val form = viewModel.duplicateForm(selected.event) ?: return@launch
selected.dismiss()
currentDuplicate(form)
}
}
val onDeleteSelected = rememberWriteAction {
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
}
val selection = host.selection ?: return null
selection.create?.let { create ->
if (selection.create != null) {
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
@@ -57,7 +73,7 @@ fun rememberFabSelection(
},
bar = {
DraftSelectionBar(
onCreate = { currentWrite { create() } },
onCreate = onCreate,
onDiscard = selection.dismiss,
)
},
@@ -79,28 +95,15 @@ fun rememberFabSelection(
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = {
currentWrite {
selection.dismiss()
currentEdit(event)
}
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onEdit = onEditSelected,
onDuplicate = onDuplicateSelected,
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
onDelete = onDeleteSelected,
)
},
)
@@ -20,7 +20,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
@@ -111,14 +110,13 @@ fun HourGutter(
}
}
// Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) }
dragStartMin?.let { badgeMin.value = it }
val badgeMin = rememberHeld(dragStartMin)
val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
badgeMin?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -45,7 +45,7 @@ class PageStateCache<K, S>(
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
state = load(key).stateWhileSubscribed(scope + job, initial),
job = job,
)
}.state
@@ -1,14 +1,8 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -22,17 +16,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
@@ -90,7 +83,7 @@ fun ReminderDefaultPicker(
}
val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
val selectedMinutes = current.minutesOrEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it.
@@ -121,24 +114,14 @@ fun ReminderDefaultPicker(
GroupedRow(
title = stringResource(R.string.reminder_use_default),
position = Position.Top,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
checked = inherits,
onClick = { apply(ReminderOverride.Inherit) },
)
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone,
selected = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
},
checked = isNone,
onClick = { apply(ReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
@@ -154,17 +137,11 @@ fun ReminderDefaultPicker(
onClick = { toggle(minute) },
)
}
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
CustomExpandRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
position = positionOf(rows.size, rowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
) {
CustomReminderEditor(
amountText = amountText,
@@ -212,19 +189,6 @@ private fun CustomReminderEditor(
)
}
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30
@@ -265,17 +229,11 @@ fun AgendaRangePicker(
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
summary = windowSummary(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
checked = option == selected,
onClick = {
onSelect(option)
onDismiss()
@@ -284,7 +242,7 @@ fun AgendaRangePicker(
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description)
GroupedHint(description, verticalPadding = 8.dp)
// Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option ->
@@ -297,7 +255,7 @@ fun AgendaRangePicker(
rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount))
}
GroupedRow(
CustomExpandRow(
title = if (customSelected) {
agendaRangeLabel(selected)
} else {
@@ -306,19 +264,10 @@ fun AgendaRangePicker(
// An unset custom window has no day count yet, so the row stays
// single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
position = positionOf(rolling.size, rollingRowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
checked = customSelected,
) {
CustomDaysEditor(
amountText = amountText,
@@ -396,17 +345,12 @@ fun DurationPicker(
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description)
if (description != null) GroupedHint(description, verticalPadding = 8.dp)
if (inheritLabel != null) {
GroupedRow(
title = inheritLabel,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
checked = inherits,
onClick = {
onSelect(null)
onDismiss()
@@ -415,39 +359,22 @@ fun DurationPicker(
Spacer(Modifier.height(24.dp))
}
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
title = label(minute),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
checked = minute == selected,
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(
CustomExpandRow(
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = custom != null,
trailing = if (custom != null) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
position = positionOf(presets.size, rowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
checked = custom != null,
) {
CustomDurationEditor(
amountText = amountText,
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.rruleParts
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
@@ -32,10 +34,7 @@ import java.util.Locale
*/
@Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone)
.toLocalDate()
}.recoverCatching {
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
}.getOrNull()
}.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -23,22 +22,21 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
@@ -189,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
@@ -11,6 +11,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -30,10 +32,13 @@ import kotlin.time.toJavaInstant
@Composable
fun SelectionTopBar(selection: EventSelection) {
val event = selection.event
// Follows a move or resize, so the times stay readable under the finger.
val preview by remember(selection) { derivedStateOf { selection.preview() } }
val shown = preview ?: event
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val whenText = remember(event.start, event.end, use24Hour, locale) {
formatSelectionWhen(event, use24Hour, locale)
val whenText = remember(shown.start, shown.end, use24Hour, locale) {
formatSelectionWhen(shown, use24Hour, locale)
}
TopAppBar(
navigationIcon = {
@@ -9,6 +9,7 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.datetime.toLocalDateTime
/**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
@@ -37,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale))
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
val time = instant.toLocalDateTime(zone).time
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
}
/**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers
@@ -118,19 +116,14 @@ fun TimeZonePickerDialog(
title = stringResource(R.string.event_edit_timezone_device),
summary = deviceSummary,
position = Position.Alone,
selected = selected == null,
checked = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) },
)
}
if (recentZones.isNotEmpty()) {
item(key = "recent_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
}
itemsIndexed(
items = recentZones,
@@ -145,7 +138,7 @@ fun TimeZonePickerDialog(
}
}
item(key = "all_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_all))
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
}
}
@@ -234,23 +227,6 @@ private fun SearchField(
}
}
/** A small primary-coloured group label, matching the settings screens. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
private fun ZoneRow(
zone: TimeZoneOption,
@@ -262,12 +238,7 @@ private fun ZoneRow(
title = zone.label,
summary = zoneDescriptor(zone),
position = position,
selected = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
checked = selected,
onClick = onClick,
)
}
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -19,8 +18,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -389,17 +390,20 @@ internal fun resizedSpan(
}
if (wrongWay) return startMin to endMin
}
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
val snapped = snapToSlot(edgeMin)
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
}
}
/** [minutes] rounded to the nearest [DRAG_SNAP_MINUTES] slot. */
internal fun snapToSlot(minutes: Float): Int = (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return snapToSlot(minutes)
}
/** How far past its column's side the finger has to go before a resize changes day. */
@@ -467,6 +471,10 @@ class TimelineDragController {
var settling: TimelineDrag? by mutableStateOf(null)
private set
/** How many real drops this controller has handed back. */
var drops by mutableIntStateOf(0)
private set
/**
* Which block is lifted, and whether anything is. Separate snapshot state
* from [drag], which changes every frame, so blocks that only need "am I the
@@ -618,25 +626,8 @@ class TimelineDragController {
*/
private var originSlot: Triple<LocalDate, Int, Int>? = null
/** The edge a resize is dragging, or null for a plain drag (#141). */
private var resizeEdge: ResizeEdge? = null
/** The day the resized event starts on; its edges are minutes from that midnight. */
private var resizeDay: LocalDate? = null
private var resizeStartMin = 0
private var resizeEndMin = 0
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
private var resizeGrabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
private var resizeColumn: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
private var resizeSlot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
private var resizeEdgeDay = 0
/** The resize in progress, or null for a plain drag (#141). */
private var resize: ResizeSession? = null
fun begin(
block: TimedBlock,
@@ -683,10 +674,7 @@ class TimelineDragController {
}
fun cancel() {
resizeEdge = null
resizeDay = null
resizeColumn = null
resizeSlot = null
resize = null
source = null
clipOffsetMin = 0
dragZone = null
@@ -761,6 +749,12 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
@@ -770,7 +764,8 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
settledEnd = end
drops++
}
/**
@@ -785,11 +780,9 @@ class TimelineDragController {
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
source = block
resizeEdge = edge
resizeDay = span.day
resizeStartMin = span.startMin
resizeEndMin = span.endMin
resize = session
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
@@ -798,17 +791,36 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
session.column = edgeIndex.takeIf { it >= 0 }
?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
if (edge == ResizeEdge.Top) session.startMin else session.endMin
originSlot = null
recompute()
originSlot = drag?.slot
}
/** The slot the gesture would land on, changing once per snap rather than per frame. */
private val landingSlot by derivedStateOf { drag?.slot }
/**
* The dragged event at the times letting go now would give it, worked out as
* [finish] and [finishResize] do. Null while it is still on its own slot (#141).
*/
fun preview(zone: TimeZone): EventInstance? {
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
val event = source?.event ?: return null
val edge = resize?.edge
if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
return event.copy(start = start, end = start + dragDuration)
}
/**
* End a resize, handing back the event's new span — null when it never
* resolved or came back to the length it started at. Settles as [finish] does.
@@ -816,16 +828,11 @@ class TimelineDragController {
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
val edge = resizeEdge
val edge = resize?.edge
cancel()
if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = drop.end
settle(landed, end = drop.end)
return drop
}
@@ -833,36 +840,33 @@ class TimelineDragController {
* Where the finger is, in minutes from the resized event's start day —
* past 1440 in a later column, negative in an earlier one.
*/
private fun pointerResizeMinutes(): Float? {
private fun pointerResizeMinutes(session: ResizeSession): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val day = resizeDay ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot()
val column = stickyColumn(
val held = stickyColumn(
position = (pointer.x - origin.x) / columnPx,
held = resizeColumn,
held = session.column,
lastIndex = days.lastIndex,
)
resizeColumn = column
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(edge: ResizeEdge) {
private fun recomputeResize(session: ResizeSession) {
val block = source ?: return
val day = resizeDay ?: return
val raw = pointerResizeMinutes() ?: return
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val edge = session.edge
val day = session.day
val raw = pointerResizeMinutes(session) ?: return
val slot = stickySnap(raw - session.grabMin, session.slot)
session.slot = slot
val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay)
val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
@@ -875,20 +879,7 @@ class TimelineDragController {
eventStartMin = start,
eventSpanMin = end - start,
titleLines = titleLines,
pieces = slices.map { slice ->
val dayIndex = startIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
pieces = pieces(slices, startIndex, origin),
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
@@ -933,7 +924,7 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
resizeEdge?.let {
resize?.let {
recomputeResize(it)
return
}
@@ -946,10 +937,9 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
val snapped = snapToSlot(rawMinutes)
val zone = dragZone ?: return
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex))
val target = days[dayIndex]
val keep = dragKept
// Measured where it would land: the drop keeps the real duration, which in
@@ -977,28 +967,34 @@ class TimelineDragController {
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
pieces = pieces(slices, dayIndex, origin),
edgeOnly = onGrid.isEmpty(),
)
}
/** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */
private fun pieces(slices: List<DragSlice>, dayIndex: Int, origin: Offset): List<TimelineDragPiece> {
val columnPx = geometry.columnWidthPx
return slices.map { slice ->
val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + column(dayIndex + slice.dayOffset) * columnPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
}
}
/** The on-screen column of a day index, and back: right-to-left lays the days out mirrored. */
private fun column(index: Int): Int = if (geometry.isRtl) geometry.days.lastIndex - index else index
/**
* Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, since a loop of
@@ -1031,6 +1027,27 @@ class TimelineDragController {
}
}
/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */
private class ResizeSession(
val edge: ResizeEdge,
/** The day the resized event starts on. */
val day: LocalDate,
val startMin: Int,
val endMin: Int,
) {
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
var grabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
var column: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
var slot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
var edgeDay = 0
}
@Composable
fun rememberTimelineDragController(): TimelineDragController {
val controller = remember { TimelineDragController() }
@@ -1160,7 +1177,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot)
DragSnapHaptics(controller.drag?.slot, controller.drops)
// Only once the finger has moved: a long press held near an edge must not
// scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
@@ -16,8 +16,6 @@ data class TimelineHours(
val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR,
) {
val span: Int get() = endHour - startHour
companion object {
const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22
@@ -1,145 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/**
* The viewport a view preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling every preview from one fixed
* height keeps them comparable — each shows the same slice of screen.
*/
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
/**
* Renders [content] at a full phone viewport and shrinks the finished layout
* into a box [height] tall, for the settings and onboarding choosers. Callers
* pass the *real* view composables, so a preview cannot drift from what it
* depicts.
*
* The [layout] below ignores the incoming constraints so the view measures at a
* phone viewport, and a [graphicsLayer] scale shrinks the result.
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
* overflows its constraints, leaving only the bottom-right corner in the clip.
*/
@Composable
internal fun ScaledViewPreview(
height: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// view's own clickables see them, and give screen readers one label
// instead of six weeks of day cells.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes
.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { }
// Measure at a full phone viewport but report the scaled size, so the
// node occupies exactly what it draws.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
},
) {
Column(Modifier.fillMaxSize()) { content() }
}
}
/**
* The stage every preview picker sets its preview on, cross-fading as [selected]
* changes. A preview draws the app's own surface, so a tonal plate gives it the
* edge it would otherwise lack on a chooser screen; the plate takes the width
* and outer radius of the grouped rows underneath so the two read as one stack.
*
* Only vertical spacing is the caller's to pass — the horizontal inset is the
* rows' own, and the alignment is the point.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset)
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the plate rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
private val FRAME_INSET = 8.dp
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -58,7 +56,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -87,16 +84,18 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.InlineStyle
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
@@ -104,7 +103,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -171,14 +169,11 @@ fun EventDetailScreen(
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place and continues straight into the tapped action.
val requireWrite = rememberWriteGate()
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) }
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
val onEditClick = rememberWriteAction(action = onEdit)
// Duplicate reads the loaded detail into a create form and hands it up; the
// create still needs WRITE_CALENDAR even when the source is read-only.
val onDuplicateClick = {
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
@@ -304,7 +299,7 @@ private fun EventDetailContent(
val detail = state.detail
val instance = detail.instance
val dark = LocalFloretDarkTheme.current
val locale = currentDetailLocale()
val locale = currentLocale()
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column(
@@ -385,7 +380,7 @@ private fun EventDetailContent(
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
Spacer(Modifier.height(2.dp))
@@ -410,7 +405,7 @@ private fun EventDetailContent(
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone),
) {
@@ -423,7 +418,7 @@ private fun EventDetailContent(
// — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
val originalTime = zoneOrNull(zoneOption.id)
?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))
@@ -442,13 +437,13 @@ private fun EventDetailContent(
// Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed.
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.CalendarMonth,
iconTint = accent,
iconContentDescription = stringResource(R.string.event_detail_calendar),
) {
Text(
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
text = state.calendarName?.orUnnamed()
?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium,
)
@@ -460,7 +455,7 @@ private fun EventDetailContent(
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.Place,
iconContentDescription = locationLabel,
) {
@@ -487,7 +482,7 @@ private fun EventDetailContent(
state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer
@@ -506,7 +501,7 @@ private fun EventDetailContent(
// each attendee with their role and reply.
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees),
) {
@@ -528,7 +523,7 @@ private fun EventDetailContent(
// Reminders (conditional) — list each lead time before the event.
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders),
) {
@@ -537,7 +532,7 @@ private fun EventDetailContent(
.sortedBy { it.minutes }
.forEach { reminder ->
Text(
text = reminderLeadText(reminder),
text = reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
@@ -548,7 +543,7 @@ private fun EventDetailContent(
// Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap))
DetailCard(
IconInfoCard(
icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence),
) {
@@ -561,36 +556,6 @@ private fun EventDetailContent(
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
@Composable
private fun AttendeeRow(attendee: Attendee) {
Row(
@@ -714,11 +679,6 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) {
// --- helpers -------------------------------------------------------------
// Observable locale read (shared helper) — avoids NonObservableLocale /
// LocalContextConfigurationRead lint by going through LocalConfiguration.
@Composable
private fun currentDetailLocale(): Locale = currentLocale()
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
AttendeeStatus.Declined -> R.string.event_attendee_declined
@@ -738,10 +698,6 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
else -> null
}
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/**
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* pinned to a zone different from the device's — the cases where showing it
@@ -6,8 +6,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -17,11 +17,12 @@ import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
@@ -30,9 +31,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
@@ -46,6 +45,7 @@ class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
@@ -76,9 +76,8 @@ class EventDetailViewModel @Inject constructor(
}
}
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading,
)
@@ -143,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -23,7 +22,6 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.nextFullHour
import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -43,6 +41,8 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_O
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -339,9 +339,8 @@ class EventEditViewModel @Inject constructor(
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
)
}
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
@@ -591,6 +590,9 @@ class EventEditViewModel @Inject constructor(
}
}
/** The form as the user has it now, or null when none is open. */
fun currentForm(): EventForm? = _form.value
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
session++
@@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
@@ -43,6 +45,7 @@ fun CalendarFilterList(
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
@@ -56,23 +59,15 @@ fun CalendarFilterList(
*/
@Composable
private fun FilterList(
groups: List<AccountGroup>,
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
Text(
text = if (group.ambiguous) {
stringResource(
R.string.calendars_account_from_source,
account,
sourceAppName(group.accountType),
)
} else {
account
},
text = accountGroupTitle(group),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
@@ -83,18 +78,19 @@ private fun FilterList(
),
)
group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
GroupedRow(
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
title = cal.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
trailing = {
Checkbox(
checked = cal.visible,
checked = visible,
onCheckedChange = { onSetVisible(cal.id, it) },
)
},
onClick = { onSetVisible(cal.id, !cal.visible) },
onClick = { onSetVisible(cal.id, !visible) },
)
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
/**
* State for the calendar-filter sheet (M3). The user toggles per-calendar
@@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
sealed interface FilterUiState {
data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState
data class Success(val groups: List<AccountGroup>) : FilterUiState
/** A calendar shows when its id is *not* in [hidden]. */
data class Success(
val groups: List<CalendarAccountGroup>,
val hidden: Set<Long>,
) : FilterUiState
}
/**
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
*
* [accountType] and [ambiguous] carry what the header needs to tell two
* same-named accounts from different apps apart (#77); the label itself is
* built in the UI layer, which is where the source app's name can be looked up.
*/
data class AccountGroup(
val account: String,
val accountType: String,
val ambiguous: Boolean,
val calendars: List<CalendarRow>,
)
data class CalendarRow(
val id: Long,
val displayName: String,
val color: Int,
val visible: Boolean,
)
@@ -4,20 +4,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -40,14 +38,13 @@ class FilterViewModel @Inject constructor(
if (failure != null) {
FilterUiState.Failure(failure)
} else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
FilterUiState.Success(enabled.groupByAccount(), hidden)
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FilterUiState.Loading,
)
@@ -61,28 +58,3 @@ class FilterViewModel @Inject constructor(
}
}
/**
* Group calendars under their owning account — by name *and* type, so two
* accounts that merely share a name stay apart (#77) — preserving the
* provider's order within each group and ordering groups by first appearance.
* A calendar is "visible" when its id is *not* in [hidden].
*/
internal fun groupCalendarsForFilter(
calendars: List<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
calendars.groupByAccount().map { group ->
AccountGroup(
account = group.label,
accountType = group.accountType,
ambiguous = group.ambiguous,
calendars = group.calendars.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}
@@ -90,8 +90,10 @@ fun ImportScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
// Read live: the state composed with may be the previous import's result,
// which the load above has already replaced.
LaunchedEffect(state) {
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
// Hoisted target calendar so the always-visible top-bar Import action can
@@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
/**
@@ -23,8 +22,7 @@ internal fun monthChipTime(
locale: Locale,
): String? {
if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
return formatTimeOfDay(event.start, zone, is24Hour, locale)
}
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -133,6 +134,10 @@ class MonthDragController {
var settling: MonthChipDrag? by mutableStateOf(null)
private set
/** How many real drops this controller has handed back. */
var drops by mutableIntStateOf(0)
private set
/**
* Which chip is lifted, and whether anything is. Separate snapshot state from
* [drag], which changes every frame, so the chips and rows that only need
@@ -291,6 +296,7 @@ class MonthDragController {
settledGhost = true
undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
drops++
return MonthChipDrop(landed.event, landed.grabDate, target)
}
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -21,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
@@ -67,7 +68,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -75,13 +75,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -167,8 +164,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.isPastOn
@@ -198,10 +193,13 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -350,7 +348,7 @@ fun MonthScreen(
if (dense) {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
midweek.yearMonth
} else {
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
}
@@ -399,7 +397,7 @@ fun MonthScreen(
}
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
val isOnCurrentMonth = titleMonth == today.yearMonth
var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged)
}
@@ -428,7 +426,7 @@ fun MonthScreen(
val jumpToToday: () -> Unit = {
todayPulse++
if (scrolling) {
val targetMonth = YearMonth(today.year, today.month)
val targetMonth = today.yearMonth
pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId
viewModel.goToToday()
@@ -446,13 +444,13 @@ fun MonthScreen(
}
} else {
viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month))
jumpToMonth(today.yearMonth)
}
}
// Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) {
val targetMonth = YearMonth(target.year, target.month)
val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId
viewModel.goToDate(target)
@@ -470,7 +468,7 @@ fun MonthScreen(
}
} else {
viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month))
jumpToMonth(target.yearMonth)
}
}
@@ -534,7 +532,7 @@ fun MonthScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
} else {
SplitMonthContent(
state = state,
selected = selectedDate,
@@ -554,23 +552,12 @@ fun MonthScreen(
// still settling would land and take the tap back.
onSelectDay = { date ->
viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month))
jumpToMonth(date.yearMonth)
},
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = { onCreateEvent(it, null) },
)
} else {
MonthContent(
state = state,
pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
}
@@ -596,7 +583,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate)
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
// Hold the landed chip until the write is done — including the time a
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
// it to its seat: it was dropped at whatever height the finger was at, while
@@ -679,118 +666,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
}
}
@Composable
private fun MonthContent(
state: MonthUiState,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val fadeSpec = rememberCalendarFadeSpec()
// Loading and failure concern the provider as a whole, so they stand in for
// the pager; a page still loading its own month handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "month-state",
) { s ->
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthPager(
pagerState = pagerState,
weekStart = weekStart,
monthState = monthState,
modifier = Modifier.fillMaxSize(),
) { _, page ->
MonthGrid(
state = page,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
}
}
/**
* The paged and split styles' months as pages that follow the finger and snap
* once a swipe passes half a page or is flung (#336). Only the page on screen
* carries the chip drag and the morph tags: its neighbours show some of its
* dates among their leading and trailing days, and would otherwise claim its
* drops and its shared elements.
*/
@Composable
private fun MonthPager(
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
modifier: Modifier = Modifier,
loading: @Composable () -> Unit = { MonthGridLoading() },
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val chipDrag = LocalMonthDrag.current
HorizontalPager(
state = pagerState,
modifier = modifier,
// The months either side are composed ahead, so a swipe lands on a loaded
// month rather than watching it fill in.
beyondViewportPageCount = 1,
verticalAlignment = Alignment.Top,
// A held chip moves within its month; the page stays put under it.
userScrollEnabled = chipDrag?.isDragging != true,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
MonthPage(
index = index,
active = index == pagerState.currentPage,
morphActive = morphPageActive(index, pagerState.settledPage),
weekStart = weekStart,
monthState = monthState,
loading = loading,
page = page,
)
}
}
@Composable
private fun MonthPage(
index: Int,
active: Boolean,
morphActive: Boolean,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
loading: @Composable () -> Unit,
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val today = LocalMonthToday.current
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
val viewMorph = LocalViewMorph.current
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalMonthMorph provides morph.takeIf { active },
LocalViewMorph provides viewMorph.takeIf { morphActive },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s.withToday(today))
else -> loading()
}
}
}
/**
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
@@ -850,7 +725,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
val javaDow = dow.toJavaDayOfWeek()
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium,
@@ -967,7 +842,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
/** Whether any day in this week holds more than [lanes] lanes can seat. */
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
/**
* Row height in the continuous grid. The paged grid divides the viewport between
@@ -1069,7 +944,7 @@ internal fun ContinuousMonthGrid(
modifier: Modifier = Modifier,
) {
val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val todayMonth = remember(state.today) { state.today.yearMonth }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
@@ -1463,8 +1338,14 @@ private fun SplitMonthContent(
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onPaneSwipeNext = onPaneSwipeNext,
onPaneSwipePrev = onPaneSwipePrev,
onPaneSwipeNext = {
onPaneSwipeNext()
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
},
onPaneSwipePrev = {
onPaneSwipePrev()
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
},
onClearSelection = onClearSelection,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -1649,18 +1530,30 @@ private fun SplitExpandHandle(
modifier = modifier
.fillMaxWidth()
.height(SPLIT_HANDLE_ROW_HEIGHT)
.clickable(onClick = onToggle)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
.size(width = SPLIT_HANDLE_RIPPLE_WIDTH, height = SPLIT_HANDLE_ROW_HEIGHT)
.clip(RoundedCornerShape(percent = 50))
.clickable(
interactionSource = null,
indication = ripple(),
onClick = onToggle,
)
.clearAndSetSemantics { },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
}
}
@@ -1693,6 +1586,7 @@ private val SPLIT_DOT_SIZE = 5.dp
private val SPLIT_HANDLE_WIDTH = 32.dp
private val SPLIT_HANDLE_HEIGHT = 4.dp
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
private val SPLIT_HANDLE_RIPPLE_WIDTH = SPLIT_HANDLE_WIDTH + 24.dp
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
@@ -1747,11 +1641,11 @@ internal fun SplitMonthGrid(
val inMonth = day.month == month.month && day.year == month.year
// Seated by lane rather than gathered by colour, so each dot
// is the event the expanded grid draws in that same lane.
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
SplitDayCell(
date = day,
events = seated,
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
isToday = day == state.today,
// A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement
@@ -1836,6 +1730,7 @@ private fun SplitDayCell(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.todayRipple(relativeToToday, rippleProgress)
.clip(CELL_SHAPE)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
@@ -1961,7 +1856,6 @@ private fun SplitDots(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SplitMonthAgendaPane(
state: MonthUiState.Success,
@@ -1979,33 +1873,16 @@ private fun SplitMonthAgendaPane(
val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
days.forEach { day ->
val events = state.instancesByDay[day].orEmpty()
stickyHeader(key = "header-$day") {
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
}
if (events.isEmpty()) {
item(key = "empty-$day") {
AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(day) },
)
}
} else {
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
AgendaEventRow(
event = event,
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
}
agendaDays(
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
today = state.today,
zone = state.zone,
emptyText = R.string.month_split_no_events,
onEmptyClick = onCreateEvent,
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -2236,8 +2113,8 @@ private fun MonthWeekRow(
val ripple = rememberTodayRipple(active = todayCol != null)
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(laneCap)
// Once per row rather than per frame: a drag recomposes every frame.
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
@@ -2421,7 +2298,7 @@ private fun MonthWeekRow(
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
week.spans.filter { it.lane < laneCap }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = span.event,
@@ -2493,19 +2370,13 @@ private fun MonthWeekRow(
)
}
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
// Single-day timed pills + overflow, per column, in the lanes
// [seating] gave them.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
val seating = seatings[col]
seating.seated.forEach { seat ->
if (seat.bar) return@forEach
val ev = seat.event
MonthBar(
event = ev,
dark = dark,
@@ -2517,7 +2388,7 @@ private fun MonthWeekRow(
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
y = EVENT_ROW_HEIGHT * seat.lane,
)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.then(
@@ -2533,18 +2404,10 @@ private fun MonthWeekRow(
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenEvents = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event) }
addAll(timed.drop(pillsShown.size))
}
if (seating.hidden.isNotEmpty()) {
OverflowDots(
events = hiddenEvents,
events = seating.hidden,
day = d,
total = hidden,
dark = dark,
rowHeight = overflowRow,
modifier = Modifier
@@ -2768,11 +2631,12 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
val onOpenWeek = LocalOpenWeek.current
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.background(MaterialTheme.colorScheme.secondaryContainer)
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
onOpenWeek(weekStart)
} else Modifier)
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
contentAlignment = Alignment.Center,
) {
@@ -2839,7 +2703,7 @@ private fun DayNumberCell(
private fun shortMonthName(date: LocalDate): String {
val locale = currentLocale()
return remember(date.month, locale) {
java.time.Month.of(date.month.ordinal + 1)
date.month.toJavaMonth()
.getDisplayName(JavaTextStyle.SHORT, locale)
}
}
@@ -2924,7 +2788,6 @@ private fun MonthBar(
private fun OverflowDots(
events: List<EventInstance>,
day: LocalDate,
total: Int,
dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier,
@@ -2946,7 +2809,7 @@ private fun OverflowDots(
.background(eventAccent(argb, dark, soften), CircleShape),
)
}
val extra = total - dots.size
val extra = events.size - dots.size
if (extra > 0) {
Text(
text = "+$extra",
@@ -3002,7 +2865,7 @@ private fun formatMonthTitle(
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
date = ym.firstDay.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -8,16 +8,16 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlinx.datetime.yearMonth
/**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
state = sample.continuous,
listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)),
monthIndexOf(today.yearMonth),
),
),
showWeekNumbers = false,
@@ -109,7 +109,7 @@ private fun sampleMonthState(
weekStart: DayOfWeek,
zone: TimeZone,
): SampleMonth {
val ym = YearMonth(today.year, today.month)
val ym = today.yearMonth
val first = LocalDate(ym.year, ym.month, 1)
val events = sampleMonthEvents(first, today, zone)
@@ -39,33 +39,55 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
/**
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
* How one day of a week row is seated with [laneCap] lanes: what sits in each
* lane, top-most first with empty lanes left out, and what had no lane left.
*/
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
/**
* The seating of column [col] with [laneCap] lanes — the one every reader of a
* row builds on: the bars and pills the paged grid draws, the split style's dots
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
*
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
* week: bars keep the lane the row layout gave them, and the day's timed events
* fill whatever slots are left, top-most first. Reading it here means the split
* style's dots and the paged style's bars describe a day in the *same order*, so
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
* Bars keep the lane the row layout gave them, and the day's timed events fill
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
* a multi-day event that only sits on other days of the week. Overflow is the
* exact complement, bars before pills.
*/
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
val covering = spans.filter { col in it.startCol..it.endCol }
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
covering.forEach { span ->
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
}
val timed = timedByDay[days[col]].orEmpty()
var pills = 0
for (lane in byLane.indices) {
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
}
return DaySeating(
seated = byLane.filterNotNull(),
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
)
}
/**
* The events occupying each lane of column [col], in lane order, capped at
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
* it here means the split style's dots and the paged style's bars describe a day
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
* morph into the other (#53).
*
* Deriving the dots independently — by distinct colour, as they first were —
* left dot _i_ standing for no particular event, and quietly merged two events
* that shared a calendar into a single dot.
*/
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) {
byLane[span.lane] = span.event
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = event
}
return byLane.filterNotNull()
}
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
seating(col, laneCap).seated.map { it.event }
/**
* The event drawn in lane [lane] of column [col], or null for an empty slot.
@@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val free = (0 until laneCap).filter { it !in occupied }
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
}
/**
@@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
* The events in column [col] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than
* a count because dimming is a per-event question (#79).
*/
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
return buildList {
spans.forEach { span ->
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
}
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
}
}
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
@@ -1,14 +1,16 @@
package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -19,10 +21,11 @@ import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -33,22 +36,19 @@ import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlin.math.abs
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import javax.inject.Inject
import kotlinx.datetime.yearMonth
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2
@@ -69,54 +69,35 @@ class MonthViewModel @Inject constructor(
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthViewStyle.Paged,
)
private val todayDate: LocalDate
get() = currentDate()
private var lastToday = todayDate
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/**
* The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
@@ -133,9 +114,8 @@ class MonthViewModel @Inject constructor(
month(ym, ws).filterNot { it is MonthUiState.Loading }
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
@@ -170,7 +150,8 @@ class MonthViewModel @Inject constructor(
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page)
realignSelection()
// Moving the grid to another month restores its full agenda.
_selectedDate.value = null
}
// --- Continuous + Dense styles (#38) ----------------------------------
@@ -195,11 +176,11 @@ class MonthViewModel @Inject constructor(
/** Last reported visible range, so a widening step can re-centre on it. */
@Volatile
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
private var visibleMonths = monthIndexOf(todayDate.yearMonth)
.let { it..it }
private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
monthIndexOf(todayDate.yearMonth)
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
)
@@ -215,13 +196,11 @@ class MonthViewModel @Inject constructor(
// Widened to whole grid weeks at both ends: a month block still
// has to know about an event that starts in the boundary week's
// clipped-off days, or a bar running into the block would vanish.
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
.startOfGridWeek(ws)
val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
val last = yearMonthForIndex(window.last).lastDay
.startOfWeek(ws)
.plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
val range = dayRange(first, last, zone)
combine(
repository.calendars(),
repository.instances(range),
@@ -236,9 +215,8 @@ class MonthViewModel @Inject constructor(
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
.stateWhileSubscribed(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = ContinuousMonthUiState.Loading,
)
@@ -320,7 +298,7 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
val target = YearMonth(date.year, date.month)
val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target
}
@@ -335,17 +313,12 @@ class MonthViewModel @Inject constructor(
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
_month.value = YearMonth(next.year, next.month)
_month.value = next.yearMonth
}
}
/** Moving the grid to another month restores its full agenda. */
private fun realignSelection() {
_selectedDate.value = null
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
_month.value = todayDate.yearMonth
_selectedDate.value = null
}
@@ -356,8 +329,8 @@ class MonthViewModel @Inject constructor(
*/
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return
_month.value = YearMonth(new.year, new.month)
if (_month.value != old.yearMonth) return
_month.value = new.yearMonth
_selectedDate.value = null
}
@@ -377,7 +350,7 @@ class MonthViewModel @Inject constructor(
/** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month)
_month.value = date.yearMonth
_selectedDate.value = date
}
@@ -427,7 +400,7 @@ internal fun layoutMonthWeeks(
byDay: DayIndex,
zone: TimeZone,
): List<MonthWeek> {
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
val gridStart = ym.firstDay.startOfWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row ->
@@ -548,12 +521,8 @@ internal fun monthGridRange(
weekStart: DayOfWeek,
zone: TimeZone,
): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
val start = gridStart.atStartOfDayIn(zone)
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
return start..end
val gridStart = ym.firstDay.startOfWeek(weekStart)
return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
}
/**
@@ -581,7 +550,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
* the first of the visible month.
*/
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) {
if (today.yearMonth == month) {
today
} else {
LocalDate(month.year, month.month, 1)
@@ -649,8 +618,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
}
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/**
* The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it
@@ -665,12 +632,12 @@ private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
return base.daysUntil(date.startOfWeek(weekStart)) / 7
}
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
@@ -681,7 +648,7 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
// The row's midweek day: a boundary week is reported as whichever month owns
// most of it, the same rule the title uses.
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
return monthIndexOf(YearMonth(midweek.year, midweek.month))
return monthIndexOf(midweek.yearMonth)
}
/**
@@ -690,19 +657,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
* laid out too, rather than flickering as placeholders.
*/
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
val lastMonthEnd = yearMonthForIndex(window.last).lastDay
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
}
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym)
val first = ym.firstDay
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
return (leadOffset + daysInMonth + 6) / 7
return (leadOffset + ym.numberOfDays + 6) / 7
}
/**
@@ -746,9 +710,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
countByDay = week.countByDay.filterKeys { it in own },
)
}
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -10,17 +10,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -53,15 +50,10 @@ internal fun BackupStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CloudOff) },
actions = {
Button(
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_backup_enable_button),
onClick = { runCatching { pickFolder.launch(null) } },
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_backup_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
)
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
@@ -69,20 +61,9 @@ internal fun BackupStep(
Text(stringResource(R.string.onboarding_backup_skip_button))
}
},
headline = stringResource(R.string.onboarding_backup_title),
supporting = stringResource(R.string.onboarding_backup_body),
) {
Text(
text = stringResource(R.string.onboarding_backup_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_backup_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -30,6 +29,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
import de.jeanlucmakiola.calendula.ui.settings.openUrl
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -108,18 +108,13 @@ internal fun CalendarsStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CalendarMonth) },
actions = {
Button(
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_local_button),
onClick = {
onClearCreation()
creating = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_local_button),
style = MaterialTheme.typography.titleMedium,
)
}
)
OutlinedButton(
onClick = { openUrl(context, DAVX5_URL) },
modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -133,20 +128,9 @@ internal fun CalendarsStep(
Text(stringResource(R.string.onboarding_calendars_skip_button))
}
},
headline = stringResource(R.string.onboarding_calendars_title),
supporting = stringResource(R.string.onboarding_calendars_body),
) {
Text(
text = stringResource(R.string.onboarding_calendars_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (creation is CalendarCreation.Failed) {
Spacer(Modifier.height(12.dp))
Text(
@@ -189,30 +173,14 @@ private fun CalendarCreated(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CheckCircle) },
actions = {
Button(
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_created_continue),
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_continue),
style = MaterialTheme.typography.titleMedium,
)
}
)
},
headline = stringResource(R.string.onboarding_calendars_created_title, name),
supporting = stringResource(R.string.onboarding_calendars_created_body),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_title, name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_created_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(

Some files were not shown because too many files have changed in this diff Show More