Compare commits

..
14 Commits
Author SHA1 Message Date
makiolaj e880500ed8 Release v2.23.0-beta.4 (#178, #181, #271, #306, #339, #387)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-14 17:55:05 +02:00
Jean-Luc Makiolaandmakiolaj 0121aa5b1d Keep the edit form's results from landing on the next one (#178) (#397)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Follow-up to #394, from two review passes on the tap-opens-editor change.

- The edit form's view model is reused between opens, so work still running for a closed form could land on the next one. Backing out of a slow load and tapping another event showed the old event, and Save wrote to it. A delete finishing after back closed the next editor. Results from a closed form are now dropped.
- Back is ignored while a save or delete runs. The write can't be called back, so the form waits for the result and then closes or reports the error.
- Unsaved changes are now measured against the form as it opened, defaults included, instead of a flag every setter had to set.
- Taps read the setting and whether the calendar takes writes when they happen, so a tap right after a cold start or a return to the app opens the right screen.
- Without write access, the first tap asks once. A refusal opens the details from then on, until the permission is granted.
- Pinning an event to the tapped occurrence lives in one helper (`EventDetail.atOccurrence`), used by the detail, edit and selection-bar paths.

Closes #178

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/397
2026-10-14 17:49:53 +02:00
Jean-Luc Makiolaandmakiolaj cf3c4e96cd Add a black theme for AMOLED screens (#181) (#399)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Settings → Appearance → Theme gets a fourth option, Black, next to System, Light and Dark. It's the dark theme with pure black backgrounds, and it works with Material You as well as Calendula's own palette.

- The month cells and the hour grid are `surfaceContainer` tiles on a `surface` ground, so making every container black would erase the grid lines. floret-kit's `toPureBlack()` sets the ground to #000 and scales the containers down to about #13, #1A, #25 and #30. The tiles stay a shade above black, and sheets and menus still stand out from the page.
- Black always means dark. It's an option in the theme picker, so it doesn't follow the system's light/dark setting.
- Event colours read `isSystemInDarkTheme()` in 15 places, so an in-app Dark or Black on a phone in light mode got light-theme tints. They now read `LocalFloretDarkTheme`, the theme's own decision. The bug also affected Dark, so the changelog has a Fixed entry for it.
- The window backdrop came from the night resource qualifier, so an in-app theme flashed the system's light or dark background before the first frame. `WindowThemeCache` keeps a SharedPreferences copy of the theme choice, and `onCreate` reads it to set the backdrop and bar icons. The copy is written when the stored setting loads, so the first cold start after updating still shows the old backdrop.
- Widgets keep their current themes. They get their theme work in the next milestone.

Needs jlmakiola/floret-kit#13, which is merged. The submodule is pinned to its merge commit on floret-kit `main` (`b1905da`).

Tested with new unit tests for the transform and for saving and reloading `BLACK`, and by hand on the emulator.

Closes #181

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/399
2026-10-14 17:41:34 +02:00
Jean-Luc Makiola 0dd7e07aa0 Show HTML event descriptions as formatted text (#271) (#398)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Event descriptions from Exchange/Outlook and Google Calendar are often HTML. The detail view now renders them instead of showing the raw markup.

- A new converter in `domain/HtmlText.kt` turns HTML into text plus styles and links. It only kicks in when the description has real tags, so plain text with `<3` or `<https://…>` is left alone.
- Kept: bold, italic, underline, strikethrough, super/subscript, monospace, line breaks, paragraphs, lists (bullets, numbers, `start`, nesting) and `<a href>` links (http(s), mailto, tel). Dropped: fonts, sizes, colours, images, and `<head>`/`<style>`/`<script>`/comments.
- Each table row becomes one line with its cells joined by " · ", and empty spacer cells disappear. There's no real grid yet; one can go on top later if invites need it.
- URL and phone autolinking runs on the rendered text and skips text that's already a link.
- Long-press copy gives the rendered text, with each link's address in brackets after it.
- Search matches the rendered text and link addresses, and snippets show clean text.
- The edit form still shows the raw HTML, as discussed in the issue, so saving never breaks the formatting.

The issue suggested a settings toggle that strips tags. Following the discussion in the comments, this renders HTML automatically instead, with no setting.

Known limit: the SQL pre-filter in search still runs on the raw column. A word that's stored as an entity (`R&amp;D`) or split by tags (`<b>Kick</b>off`) isn't found.

I wrote my own parser instead of using `AnnotatedString.fromHtml`, because that goes through `android.text.Html`, which is stubbed in JVM unit tests, and search lives in the Compose-free domain layer.

Closes #271
2026-10-14 17:30:16 +02:00
Jean-Luc Makiolaandmakiolaj 2b2a31ecda Create an event from shared or selected text (#306) (#396)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Shared text, or text picked with the new "Create event" entry in the selection menu, opens the new-event form prefilled. The share's subject becomes the title, or the first line if there is no subject, and the rest of the text goes into the description. Start and length use the usual defaults (next full hour, default duration). Calendula doesn't try to read a date out of the text.

Differences from the plan in the issue comment:
- The line used as the title is left out of the description, so it doesn't show up twice.
- Titles are capped at 255 characters. If a title gets cut, the full text stays in the description.
- Sharing a plain-text file no longer sends it to the .ics importer.
- A saved or dismissed prefilled form no longer reopens after rotating the phone. This also fixes it for "add to calendar" requests from other apps.
- If a second request comes in while a prefilled form is open, it replaces the form when nothing was changed. Otherwise the save / discard / keep editing prompt comes first.
- If Calendula was started just for the request, closing or saving the form takes you back to the app you came from.

Closes #306

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/396
2026-10-14 15:57:32 +02:00
Jean-Luc Makiolaandmakiolaj 0f75d2638b Show events at their real time on days the clocks change (#387) (#393)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
On the day the clocks change, the day, 3-day and week views placed events by the time elapsed since midnight, so every event after the change sat an hour off. A 14:45 event on 25 October showed at 15:45. Blocks are now placed by wall clock.

When the clocks go back, 02:00 to 03:00 happens twice, and a wall-clock time alone can't say which of the two it means. This branch keeps track of it:

- The event form holds each time as a `FormTime`, the wall clock plus its UTC offset when that time is in the repeated hour. Validation and saving read the real instants from it, so a span ending in the repeated hour is valid and switching between the two passes is saved.
- A time picked in the form or dragged with a resize handle goes back to the event's original pass when it's set back to its original time. Otherwise it moves in the direction it was moved, stays after the start, and takes a pass the grid can draw where it was dropped.
- A drop onto a repeated time lands on the first pass, unless the event was already on the second.
- A block that runs into the repeated hour from before it is drawn to 03:00, and its label shows its real end.
- The editor, the event details and the pinned zone's "local time" line add the zone abbreviation (CEST or CET) to a time in the repeated hour.
- A recurring series that starts in the repeated hour keeps its pass when it's edited.

Also in this PR:

- `ZoneSource`, provided through Hilt, gives the timeline, the event form and reschedules the device zone. The timeline lays its pages out again when the zone changes.
- A drafted block (#153) opens the form at its exact start and end.
- A day move onto the spring-forward day keeps its wall-clock start, so moving a weekly series there is still offered for all events.

## Testing

The 1,258 unit tests pass. The instrumented suite has the same two failures as `release/v2.23.0` (`CalendarRepositorySmokeTest` and `PermissionScreenTest`), so nothing new.

I also checked it by hand on an emulator in Europe/Berlin, with the clock moved to each DST day:

- 25 October: the event from the issue (14:45, Europe/Brussels) is drawn at 14:45. A run from 02:10 CEST to 02:20 CET is drawn to 03:00 and labelled with its real end.
- Pulling the end of a 02:30 to 03:30 CET event up to 02:45 saves 02:45 CET, after its start.
- Pulling the end of a 01:00 CEST to 04:00 CET event up to 02:15 saves 02:15 CEST, and the block stays where it was dropped.
- 29 March: a 01:00 to 04:00 run and a 14:00 event both sit at their own times.

Picking times in the form and moving a series in the month view are covered by unit tests only.

Closes #387

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/393
2026-10-14 15:54:14 +02:00
Jean-Luc Makiolaandmakiolaj 9fe1ebb0fb Tap a phone number in an event to call it (#339) (#395)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Phone numbers in an event's title, location and description are now links. Tapping one opens the dialer with the number filled in. Nothing is called until you press call, so the app needs no new permission.

- Android's own matcher finds the numbers (Linkify, backed by libphonenumber and the SIM's region). Its loose setting links too much in event text, so a cleanup step drops meeting IDs and PINs after a label, dates, dotted times like "9.30 – 12.00", amounts, anything under 7 digits and digits inside URLs.
- "+49 (0)151 …" dials without the (0). An extension ("ext. 567") becomes a pause, and a Zoom-style dial-in suffix (",,850…#") is kept.
- Detection runs in the detail ViewModel while the event loads, so the links are there on the first frame and survive rotation.
- Long-pressing a field copies it even when the press lands on a link. Before, the link swallowed the press.
- PhoneLinksTest covers the cleanup on the JVM. PhoneLinksPlatformTest (androidTest) runs the real matcher and passes on an emulator with a US SIM.

Known limit: on a device without a SIM (Wi-Fi tablets, some emulators) Android gives the matcher no region, so only numbers starting with + are linked.

Closes #339

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/395
2026-10-14 15:24:03 +02:00
Jean-Luc Makiolaandmakiolaj 951ad9cd6d Tapping an event can open the edit form (#178) (#394)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
New setting under Settings → Views → Events: "Tapping an event opens" Details (default) or Edit form. The long-press toggle moves into the same group.

- Taps in the views and in search open the editor directly for events in writable calendars. Read-only events, reminder notifications and widgets always open the details.
- Installs without write access are asked for it on the tap; refusing opens the details instead.
- The editor gets a back guard: with unsaved changes it asks to save, discard or keep editing; otherwise it closes silently.
- The editor gets Duplicate, Share and Delete in an overflow menu, so skipping the details doesn't lose them.
- If the event can't be loaded, the details screen opens and shows why.
- Each editor overlay (create, edit, duplicate/import) now has its own ViewModel. Duplicating from an edited form previously reused the edit form's state.
- Deletes from the details screen, the editor and the selection bar share one error mapping. Deleting an event that's already gone counts as deleted in the details screen and the editor.

Deviation from the issue: it asked for back to save automatically. Following the discussion in the comments, auto-save isn't in this PR. Back asks only when something actually changed, and Details stays the default.

Closes #178

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/394
2026-10-14 14:33:38 +02:00
makiolaj 6cb2f62627 Release v2.23.0-beta.3 (#153)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 36m8s
2026-10-12 22:48:23 +02:00
Jean-Luc Makiolaandmakiolaj 427e87c6e6 Long press an empty slot to draft an event (#153) (#390)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Long press on free time in the day, 3-day and week views drops a block of the default event length there. It can be moved while still held, or dragged and resized with its handles after letting go. The tick opens the new-event form at that span; the ✕, a tap elsewhere or back discards it. The draft is a stand-in event that reuses the #141 selection, drag and resize machinery, so nothing is written until the form saves.

Also smooths the lift handover for all selected blocks: no one-frame dip when letting go, no bounce on the handles or on a block landing after a drop or resize, and the floating copy's time label now matches the block's so the text doesn't shift on pickup.

Deviations from the issue / agreed design:
- An unresized draft opens the form with the length it shows (the global default) rather than the per-calendar default, so the form never opens at a different time than the block on screen.
- A draft dropped on a day off screen snaps back instead of moving there, since nothing would draw it.
- The in-editor timeline picker from the issue isn't included; per the issue comments it isn't needed for now.

Also moves the Gitea release/beta jobs onto the light/gradle runners.

Closes #153

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/390
2026-10-12 22:48:13 +02:00
makiolaj dce83005ea Release v2.23.0-beta.2 (#141)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 8m15s
2026-10-09 22:23:14 +02:00
Jean-Luc Makiola 1ac486cc4b Select, move and resize timeline events with a long press (#141) (#384)
Beta — Codeberg pre-release / detect (push) Successful in 12s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-09 22:23:06 +02:00
makiolaj 549ef1dfbf Release v2.23.0-beta.1 (#367)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 9m45s
2026-10-09 10:25:48 +02:00
Jean-Luc Makiolaandmakiolaj 164b5fb25d Scroll back into past days in the agenda (#367) (#382)
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
The agenda opens on today, and scrolling up loads earlier days 30 at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today.

The agenda no longer picks up the day the month or day view was on, so "Next 30 days" always counts from today. That fixes the bug from the issue comment. It also means the agenda no longer follows the shared view date from #184 when you switch to it. Backing out of a day opened from the agenda keeps your scroll position.

Not in the issue: multi-day events that are still running now dim on the days already behind you, in the agenda, month, week and day views. Before, they stayed at full strength until the whole event ended. A spanning bar dims once the last day it covers in that row has passed.

The widget is unchanged and still starts at today.

Closes #367

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/382
2026-10-09 10:25:25 +02:00
98 changed files with 6593 additions and 1000 deletions
+25
View File
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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 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]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
- **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]).
### 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]).
## [2.22.5] — 2026-10-07
### Fixed
@@ -1848,4 +1864,13 @@ automatically, with zero telemetry and no internet permission.
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
+2 -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 = 2220599
versionName = "2.22.5"
versionCode = 2230004
versionName = "2.23.0-beta.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.detail
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
/**
* The platform phone matcher on real text. It reads the region off the SIM,
* so these stick to international numbers and to text that must never match
* anywhere — domestic formats depend on the device they run on.
*/
@RunWith(AndroidJUnit4::class)
class PhoneLinksPlatformTest {
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
@Test
fun internationalNumbers_areFound() {
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
.containsExactly("+49301234567", "+16465588656")
.inOrder()
}
@Test
fun trunkZero_isDroppedWithOrWithoutSpace() {
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
}
@Test
fun neighbouringNumbers_doNotJoinTheNumber() {
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
}
@Test
fun conferenceDialIn_keepsMeetingId() {
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
.containsExactly("+16465588656,,85012345678#")
}
@Test
fun meetingIds_areNotNumbers() {
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
}
@Test
fun datesAndTimes_areNotNumbers() {
assertThat(numbers("2026-10-14 10:00")).isEmpty()
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
assertThat(numbers("10:00 - 11:30")).isEmpty()
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
}
@Test
fun amounts_areNotNumbers() {
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
}
@Test
fun isbn_isNotANumber() {
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
}
@Test
fun extension_becomesAPause() {
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
}
@Test
fun webexAccessCode_isNotANumber() {
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
}
@Test
fun commaSeparatedNumbers_stayApart() {
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
.containsExactly("+49301234567", "+491512345678")
.inOrder()
}
@Test
fun dottedTimes_areNotNumbers() {
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
assertThat(numbers("10.00-11.30")).isEmpty()
}
@Test
fun schemelessUrlDigits_areNotNumbers() {
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
}
@Test
fun webLinkDigits_areNotNumbers() {
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
}
}
+13
View File
@@ -151,6 +151,19 @@
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/process_text_create_event">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events *dir* mime type, carrying the new
@@ -25,12 +25,15 @@ import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.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.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
@@ -50,8 +53,11 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
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.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -66,6 +72,7 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
@Inject lateinit var settingsPrefs: SettingsPrefs
// Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay
@@ -86,10 +93,18 @@ class MainActivity : AppCompatActivity() {
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). Consumed once by
// CalendarHost, which opens it in the create form for review.
// 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)
// 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.
private var insertFormOpen = false
// Started just for an external create, so closing its form returns to the
// 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.
@@ -115,9 +130,25 @@ class MainActivity : AppCompatActivity() {
return
}
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
// The stored theme only arrives after the first frame, so a Light, Dark
// or Black override starts from its last mirrored copy: the matching
// 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
}
applyEdgeToEdge()
lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode ->
WindowThemeCache.write(this@MainActivity, mode)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
}
}
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
@@ -128,7 +159,8 @@ class MainActivity : AppCompatActivity() {
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
// is what carries one through a recreation — the create form only while
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
@@ -137,7 +169,11 @@ class MainActivity : AppCompatActivity() {
requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull()
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requestedInsert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requestedInsert != null)
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -147,7 +183,7 @@ class MainActivity : AppCompatActivity() {
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.DARK, ThemeMode.BLACK -> true
}
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
@@ -187,6 +223,7 @@ class MainActivity : AppCompatActivity() {
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode == ThemeMode.BLACK,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -208,6 +245,10 @@ class MainActivity : AppCompatActivity() {
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
@@ -261,12 +302,21 @@ class MainActivity : AppCompatActivity() {
CrashReporter.markHealthy(this)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_INSERT_FORM_OPEN, insertFormOpen)
outState.putBoolean(KEY_LAUNCHED_FOR_INSERT, launchedForInsert)
}
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 }
intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.createRequestOrNull()?.let {
requestedInsert = it
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
@@ -288,7 +338,9 @@ 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 }
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -333,6 +385,36 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
}
/** Any external request to create an event: shared text or an insert intent. */
private fun Intent.createRequestOrNull(): InsertRequest? = sharedTextRequestOrNull() ?: insertRequestOrNull()
/**
* Plain text shared into us (`ACTION_SEND`) or picked from a text-selection
* menu (`ACTION_PROCESS_TEXT`), as a new event at the default time (#306).
*/
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
)
} else {
null
}
Intent.ACTION_PROCESS_TEXT ->
sharedTextOrNull(subject = null, text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString())
else -> null
} ?: return null
val form = buildInsertEventForm(
title = shared.title,
description = shared.description,
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
return InsertRequest(form, ImportSource.InsertOpenEnded)
}
/** 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
@@ -458,6 +540,9 @@ class MainActivity : AppCompatActivity() {
// The calendar provider's authority/host. A date tap arrives as
// 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 const val KEY_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
@@ -1,15 +1,19 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.instantIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneOffset
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -48,8 +52,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else {
val writeZone = writeZone(zone)
EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
timezone = writeZone.id,
)
}
@@ -75,7 +79,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.toJavaLocalDateTime()
start.dateTime.toJavaLocalDateTime()
}
/**
@@ -181,7 +185,8 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it.
// DTSTART has to move with it. So does a switch between the two passes of
// the fall-back hour, which each [FormTime] carries (#387).
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
@@ -209,15 +214,23 @@ internal fun buildEventUpdateValues(
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
val seriesLocal = seriesZoned.toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
// when the anchor is the edited occurrence itself, the pass the form now
// holds: a bare wall clock would always take the earlier one (#387).
val pass = if (seriesLocal == original.anchorLocal()) {
updated.start.offset?.toJavaZoneOffset()
} else {
seriesZoned.offset
}
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId,
title = title,
isAllDay = true,
start = start,
end = start,
start = FormTime(start),
end = FormTime(start),
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
@@ -21,7 +21,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
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.Dispatchers
import javax.inject.Singleton
@@ -69,6 +71,12 @@ abstract class DataBindModule {
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
@Binds
@Singleton
abstract fun bindZoneSource(
impl: AndroidZoneSource,
): ZoneSource
}
@Module
@@ -41,8 +41,11 @@ import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/**
* Light/dark override. SYSTEM follows the device setting; BLACK is dark with
* pure-black surfaces, for AMOLED screens (#181).
*/
enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK }
/**
* Week-start override. [Auto] derives the first day from the active locale;
@@ -346,6 +349,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
}
/**
* Whether tapping an event in the calendar views opens the edit form instead
* of the detail screen (#178). Defaults to OFF; read-only events and
* reminder notifications always open the detail screen.
*/
val tapOpensEditor: Flow<Boolean> = store.data.map { prefs ->
prefs[TAP_OPENS_EDITOR_KEY] ?: false
}
suspend fun setTapOpensEditor(enabled: Boolean) {
store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -1019,6 +1035,7 @@ class SettingsPrefs @Inject constructor(
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
@@ -0,0 +1,51 @@
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* The zone the device is in. Every screen that turns wall clock into instants
* reads it from here, so the timeline, the event form and a saved move use the
* same zone (#387).
*/
interface ZoneSource {
/** The device's zone, re-emitted whenever it changes. */
val zone: Flow<TimeZone>
/** The device's zone right now. */
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : 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) }
.map { current() }
.distinctUntilChanged()
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -17,8 +17,8 @@ data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
val start: FormTime,
val end: FormTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
@@ -70,6 +70,19 @@ data class EventForm(
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
* [offset] it was read at when the fall-back hour repeats that wall clock — both
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
* so a time changed and changed back compares equal again. A time picked by hand
* gets its pass from [repicked]; one without an offset reads as the earlier
* pass. One value, so a time never changes without its pass. See [toFormTime].
*/
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
val date: LocalDate get() = dateTime.date
val time: LocalTime get() = dateTime.time
}
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
@@ -145,16 +158,15 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
begin.toFormTime(formZone) to finish.toFormTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -221,20 +233,18 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
)
/**
* The form's times as they land in [target] — what a pinned event's wall-clock
* actually means where the user is standing. Null when there's nothing to
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
* itself, an all-day event (no zone), or an unparseable pinned zone.
* 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
* unpinned event (already in [target]), one pinned to [target] itself, an
* all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
* The form edits a pinned event in its own zone, so this is what tells the UI
* to show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
if (isAllDay) return null
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
if (pinned.id == target.id) return null
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target)
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
}
/**
@@ -254,9 +264,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
/**
* What stops this form from saving. A timed span is checked as instants in the
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
* end on an earlier wall clock than it starts (#387).
*/
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
val endsTooEarly = if (isAllDay) {
end.date < start.date
} else {
val zone = resolvedZone(deviceZone)
endInstant(zone) < startInstant(zone)
}
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view.
@@ -265,3 +285,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart)
}
}
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -162,16 +162,20 @@ object EventSearch {
val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed so a multi-line description matches the way it is drawn.
val description = candidate.description
val rich = candidate.description?.let(::descriptionRichText)
val description = rich?.text
?.replace(WHITESPACE, " ")
?.trim()
?.takeIf { it.isNotEmpty() }
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
val linkUrls = rich?.links.orEmpty().map { it.url }
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
val present = { token: String ->
title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true
description?.contains(token, ignoreCase = true) == true ||
linkUrls.any { it.contains(token, ignoreCase = true) }
}
if (!tokens.all(present)) return null
@@ -3,10 +3,19 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toJavaZoneOffset
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toLocalDateTime
import java.time.ZonedDateTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/**
* The zone this form's wall-clock times mean, matching what the data layer
@@ -15,6 +24,114 @@ import kotlin.time.Instant
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
* it and that is one of its two offsets — otherwise the earlier one (#387).
*/
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
.toInstant().toKotlinInstant()
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
val zid = zone.toJavaZoneId()
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
}
/**
* [at]'s offset in this zone when its wall clock is one the fall-back hour
* repeats, else null: elsewhere the wall clock alone already names the instant.
* What a [FormTime.offset] holds (#387).
*/
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
/**
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
* real one, as the provider would read it. A wall clock the fall-back hour
* repeats has two instants; which one it means (#387):
* - [original]'s, when [picked] is the wall clock the event was opened with, so
* a time changed and changed back is the time it was;
* - else the one that moves the edge in the direction the user moved it:
* earlier than this edge's instant when [picked] is before [shownAt] (where
* the edge was on screen), later when after;
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
* [drawable] accepts — where the screen can show it — when any fit.
*/
fun FormTime.repicked(
picked: LocalDateTime,
zone: TimeZone,
shownAt: LocalDateTime = dateTime,
original: FormTime? = null,
notBefore: Instant? = null,
notAfter: Instant? = null,
drawable: (Instant) -> Boolean = { true },
): FormTime {
if (picked == dateTime && picked == shownAt) return this
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
if (offsets.size == 1) return FormTime(picked)
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
.ifEmpty { passes }
val fitting = bounded.filter(drawable).ifEmpty { bounded }
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
val from = instantIn(zone)
val chosen = when {
picked < shownAt -> fitting.lastOrNull { it < from }
picked > shownAt -> fitting.firstOrNull { it > from }
else -> null
} ?: fitting.minBy { (it - from).absoluteValue }
return chosen.toFormTime(zone)
}
/** The form's start as an instant in [zone]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
/** The form's end as an instant in [zone]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
/** The form's timed span set to these instants, read in [zone]. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
/**
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
* was one of the old zone's, so it goes when the zone the times mean changes.
*/
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
val pinned = copy(timezone = zoneId)
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
return pinned.withoutKeptPasses()
}
/**
* The form with its start picked by hand, keeping its length. A repeated hour's
* pass is [repicked]: [original] is the start the event was opened with.
*/
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
val span = endInstant(zone) - startInstant(zone)
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
}
/**
* The form with its end picked by hand. A repeated hour's pass is [repicked],
* kept after the start where it can be: [original] is the end the event was
* opened with.
*/
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
/** The form with no kept passes: its times as the wall clock alone says them. */
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
/**
* The form moved so it starts at [newStart], keeping its **instant** duration —
* a recurring event's length travels to the provider as `DURATION`, so keeping
@@ -26,11 +143,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
val span = endInstant(zone) - startInstant(zone)
return withTimes(newStart, newStart + span, zone)
}
/**
@@ -43,11 +157,21 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy(
start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
start = FormTime(newStart),
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
)
}
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
val span = endInstant(zone) - startInstant(zone)
val startAt = FormTime(newStart, start.offset).instantIn(zone)
// The start keeps the wall clock it was moved to, even one the spring-forward
// gap skips, so a recurring series still sees a whole-day move.
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
}
@@ -0,0 +1,387 @@
package de.jeanlucmakiola.calendula.domain
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
data class LinkRange(val start: Int, val end: Int, val url: String)
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
data class RichText(
val text: String,
val styles: List<StyleRange> = emptyList(),
val links: List<LinkRange> = emptyList(),
) {
/**
* [text] with each link's address after it, where the text doesn't already
* show it, so a copied "Join the meeting" still carries its URL.
*/
fun copyText(): String {
if (links.isEmpty()) return text
val out = StringBuilder(text)
for (link in links.sortedByDescending { it.end }) {
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
}
return out.toString()
}
}
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
fun descriptionRichText(description: String): RichText =
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
private val BLOCK_TAGS = setOf(
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
)
// Their content is never text to show.
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
private val STYLE_TAGS = mapOf(
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
)
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
// Calendar and Exchange both store their descriptions as HTML.
private val htmlTagRegex = Regex(
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
RegexOption.IGNORE_CASE,
)
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
private const val NBSP = '\u00A0'
private const val CELL_SEPARATOR = " · "
/**
* Render [html] down to text with its inline formatting and links. Blocks and
* `<br>` become line breaks, list items get a bullet or number, table rows a
* line each with their cells side by side. Lenient about broken markup:
* whatever doesn't parse as a tag stays text.
*/
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
// Markup that sets its own lines. Without any, the text's newlines are the
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
private class HtmlRenderer(private val html: String) {
private val out = StringBuilder()
private val styles = mutableListOf<StyleRange>()
private val links = mutableListOf<LinkRange>()
private val openStyles = mutableListOf<Pair<String, Int>>()
private var openLink: Pair<String, Int>? = null
private val lists = mutableListOf<ListState>()
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
private var preDepth = 0
private var rowStart = 0
private var cellNeedsSeparator = false
// Where the current list item's bullet ends, so a <p> inside the item
// doesn't push its text off the bullet's line.
private var markerEnd = -1
private class ListState(val ordered: Boolean, var count: Int = 0)
fun render(): RichText {
var i = 0
while (i < html.length) {
val lt = html.indexOf('<', i)
if (lt < 0) {
text(html.substring(i))
break
}
if (lt > i) text(html.substring(i, lt))
i = tag(lt)
}
return normalize()
}
/** Handle the markup at [lt]; returns where reading continues. */
private fun tag(lt: Int): Int {
if (html.startsWith("<!--", lt)) {
val end = html.indexOf("-->", lt + 4)
return if (end < 0) html.length else end + 3
}
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
val end = html.indexOf('>', lt)
return if (end < 0) html.length else end + 1
}
val closing = html.getOrNull(lt + 1) == '/'
val nameStart = if (closing) lt + 2 else lt + 1
if (html.getOrNull(nameStart)?.isLetter() != true) {
text("<")
return lt + 1
}
val end = tagEnd(lt)
if (end < 0) {
text("<")
return lt + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
val attrs = html.substring(nameEnd, end)
if (closing) {
close(name)
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
// Never closed (an omitted </head> is valid): just ignore the tag.
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
if (closeAt < 0) return end + 1
val closeEnd = html.indexOf('>', closeAt)
return if (closeEnd < 0) html.length else closeEnd + 1
} else {
open(name, attrs)
}
return end + 1
}
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
for (j in lt + 1 until html.length) {
val c = html[j]
when {
quote != null -> if (c == quote) quote = null
c == '"' || c == '\'' -> quote = c
c == '>' -> return j
c == '<' -> return -1
}
}
return -1
}
private fun open(name: String, attrs: String) {
when (name) {
"br" -> newline()
"li" -> {
markerEnd = -1
cellNeedsSeparator = false
lineBreak()
val list = lists.lastOrNull()
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
if (list?.ordered == true) {
list.count++
out.append("${list.count}. ")
} else {
out.append("• ")
}
markerEnd = out.length
}
"ul", "ol" -> {
cellNeedsSeparator = false
lineBreak()
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
lists += ListState(ordered = name == "ol", count = start - 1)
}
"tr" -> {
lineBreak()
rowStart = out.length
cellNeedsSeparator = false
}
"td", "th" -> {
// An empty spacer cell leaves nothing behind, not even its &nbsp;.
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
cut(rowStart)
} else {
cellNeedsSeparator = true
}
}
"a" -> {
closeLink()
hrefRegex.find(attrs)
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
?.let(::decodeEntities)
?.trim()
?.let { href ->
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
?: return@let
// Intent filters match the scheme case-sensitively.
openLink = scheme + href.substring(scheme.length) to out.length
}
}
else -> if (name in BLOCK_TAGS) lineBreak()
}
if (name == "pre") preDepth++
STYLE_TAGS[name]?.let { openStyles += name to out.length }
}
private fun close(name: String) {
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
if (name == "tr" || name == "table") cellNeedsSeparator = false
val index = openStyles.indexOfLast { it.first == name }
if (index >= 0) {
val (_, start) = openStyles.removeAt(index)
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
if (name == "li" || name == "ul" || name == "ol") {
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
markerEnd = -1
}
when (name) {
"ul", "ol" -> {
lists.removeLastOrNull()
lineBreak()
}
"a" -> closeLink()
else -> if (name in BLOCK_TAGS) lineBreak()
}
}
private fun closeLink() {
val (url, start) = openLink ?: return
if (out.length > start) links += LinkRange(start, out.length, url)
openLink = null
}
private fun text(raw: String) {
val decoded = decodeEntities(raw)
if (preDepth > 0) {
out.append(decoded.replace("\r\n", "\n"))
return
}
for (c in decoded) {
if (c == '\n' && keepNewlines) {
newline()
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
} else {
if (cellNeedsSeparator && c.isContent()) separateCell()
out.append(c)
}
}
}
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
private fun separateCell() {
cellNeedsSeparator = false
var end = out.length
while (end > rowStart && !out[end - 1].isContent()) end--
cut(end)
out.append(CELL_SEPARATOR)
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
}
private fun Char.isContent() = !isWhitespace() && this != NBSP
private fun newline() {
trimTrailingSpaces()
out.append('\n')
}
/** End the current line, unless it's already ended or holds just a bullet. */
private fun lineBreak() {
if (out.length == markerEnd) return
trimTrailingSpaces()
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
}
private fun trimTrailingSpaces() {
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
}
/** Drop the output from [length] on, with whatever formatting lay only there. */
private fun cut(length: Int) {
if (length >= out.length) return
out.setLength(length)
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
styles.removeAll { it.end <= it.start }
links.replaceAll { it.copy(end = minOf(it.end, length)) }
links.removeAll { it.end <= it.start }
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
}
/**
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
* move with the text they cover.
*/
private fun normalize(): RichText {
closeLink()
for ((name, start) in openStyles) {
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
val src = out.toString().replace(NBSP, ' ')
val map = IntArray(src.length + 1)
val result = StringBuilder()
var i = 0
while (i < src.length) {
var lineEnd = src.indexOf('\n', i)
if (lineEnd < 0) lineEnd = src.length
var contentEnd = lineEnd
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
for (j in i until lineEnd) {
map[j] = result.length
if (j < contentEnd) result.append(src[j])
}
if (lineEnd < src.length) {
map[lineEnd] = result.length
if (!result.endsWith("\n\n")) result.append('\n')
}
i = lineEnd + 1
}
map[src.length] = result.length
val leading = result.length - result.trimStart().length
val text = result.trim().toString()
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
return RichText(
text = text,
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
)
}
}
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
// Latin-1 (&nbsp; … &yuml;) in code point order, then the common extras.
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
)
// A named reference needs its ";": "?id=5&reg=eu" in a link is a query, not "®".
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
/** Decode character references; an unknown name is left as written. */
internal fun decodeEntities(text: String): String {
if ('&' !in text) return text
return entityRegex.replace(text) { match ->
val name = match.groupValues[2]
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
val body = match.groupValues[1]
val code = when {
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
else -> body.substring(1).toIntOrNull()
}
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
}
}
@@ -4,7 +4,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@@ -25,13 +24,13 @@ import kotlin.time.Instant
* the extras lives in `MainActivity.insertFormOrNull`.
*/
fun buildInsertEventForm(
beginMillis: Long?,
endMillis: Long?,
isAllDay: Boolean,
beginMillis: Long? = null,
endMillis: Long? = null,
isAllDay: Boolean = false,
title: String?,
description: String?,
location: String?,
rrule: String?,
location: String? = null,
rrule: String? = null,
zone: TimeZone,
now: Instant,
): EventForm {
@@ -46,16 +45,12 @@ fun buildInsertEventForm(
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
val startTime = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?: nextFullHour(now, zone)
val endTime = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?.takeIf { it >= startTime }
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
startTime to endTime
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt.toFormTime(zone) to endAt.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -71,8 +66,34 @@ fun buildInsertEventForm(
)
}
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
/** The first full hour after [now]: may roll into the next day before midnight. */
internal fun nextFullHour(now: Instant): Instant {
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
}
/** Title and description for text shared or selected into the app (#306). */
data class SharedText(val title: String, val description: String)
/**
* Split shared text into an event title and description: the share's subject
* becomes the title and the text the description. Without a subject the text's
* first non-blank line is the title and the rest the description. A title longer
* than [maxTitle] is cut, and the full text then stays in the description.
* Null when there is nothing to use.
*/
fun sharedTextOrNull(subject: String?, text: String?, maxTitle: Int = 255): SharedText? {
val body = text?.trim().orEmpty()
val given = subject?.trim()?.takeIf { it.isNotEmpty() }
val (title, rest) = if (given != null) {
given to body
} else {
val lines = body.lines()
val first = lines.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
first to lines.drop(1).joinToString("\n").trim()
}
if (title.length <= maxTitle) return SharedText(title, rest)
val cut = title.take(maxTitle).let { if (it.last().isHighSurrogate()) it.dropLast(1) else it }
val full = if (given != null) listOf(given, body).filter { it.isNotEmpty() }.joinToString("\n\n") else body
return SharedText(cut.trimEnd(), full)
}
@@ -165,6 +165,17 @@ data class EventDetail(
val isException: Boolean = false,
)
/**
* This event on one occurrence's times. The Events row holds the series start,
* so a recurring event needs the tapped occurrence pinned to render and share right.
*/
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
instance = instance.copy(
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
),
)
/**
* One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
@@ -157,14 +157,36 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
val name = zoneAbbreviation(option)
val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
}
/**
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
* normalised to our GMT form, as [zoneDescriptor] spells it.
*/
fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes)
/**
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
* without the localized long name a whole [TimeZoneOption] would look up. Null
* for an id the tz database can't parse.
*/
fun zoneAbbreviationAt(
id: String,
locale: Locale,
at: Instant,
regionOf: (String) -> String? = { null },
): String? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
}
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
/**
* [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -24,9 +26,9 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
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))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
this.start.toFormTime(zone) to this.end.toFormTime(zone)
}
return EventForm(
calendarId = null,
@@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -29,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
@@ -55,7 +57,13 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
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.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -76,6 +84,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditViewModel
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
@@ -91,6 +100,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Holds the top-level view back stack (spec M1) and swaps between the calendar
@@ -126,6 +136,7 @@ fun CalendarHost(
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
@@ -146,6 +157,9 @@ fun CalendarHost(
mutableStateOf(listOf(defaultView))
}
var navigationVersion by remember { mutableIntStateOf(0) }
// True after backing out of a day opened from the view now shown, so the
// agenda returns to where it was instead of today (#367).
var returnedFromDay by remember { mutableStateOf(false) }
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.
@@ -163,6 +177,7 @@ fun CalendarHost(
val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++
returnedFromDay = false
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
@@ -184,7 +199,7 @@ fun CalendarHost(
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onEventClick: (EventInstance) -> Unit = { event ->
val onOpenDetail: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
@@ -231,11 +246,23 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
// 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
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
}
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -255,6 +282,34 @@ fun CalendarHost(
// 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) }
// Bumped per open, so even an equal prefill starts a fresh form.
var importFormId by remember { 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")
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
importFormSource = source
importForm = form
importFormId++
importFromRequest = fromRequest
}
fun closeImportForm() {
val next = pendingInsert
pendingInsert = null
if (next != null) {
openImportForm(next.first, next.second, fromRequest = true)
} else {
importForm = null
if (importFromRequest) {
importFromRequest = false
onExternalFormClosed()
}
}
}
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
@@ -284,8 +339,11 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importFormSource = requestedInsertSource
importForm = requestedInsertForm
if (importForm == null) {
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true)
} else {
pendingInsert = requestedInsertForm to requestedInsertSource
}
onInsertConsumed()
}
}
@@ -301,6 +359,9 @@ fun CalendarHost(
editKey = null
importUri = null
importForm = null
importFromRequest = false
pendingInsert = null
importViewModel.reset()
}
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
@@ -322,7 +383,10 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
if (widgetNavRequest != null) {
navigationVersion++
returnedFromDay = false
}
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
@@ -369,6 +433,8 @@ fun CalendarHost(
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
onWidgetNavConsumed()
}
null -> {}
@@ -379,6 +445,9 @@ fun CalendarHost(
// survives view switches; the calendar surfaces read it out of LocalEventMove.
val reschedule: RescheduleViewModel = hiltViewModel()
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
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,
@@ -388,6 +457,28 @@ fun CalendarHost(
heldEditKey = key
editKey = key
}
// 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) }
val onEventClick: (EventInstance) -> Unit = { event ->
tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
when {
!toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event)
else -> requireWriteOr({ onEditEvent(event) }) {
tapWriteRefused = true
onOpenDetail(event)
}
}
}
}
// Off by preference (#173) switches off the gesture, not the scope: blocks
// register no drag, while the edit form and its TalkBack action stay put.
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
@@ -424,11 +515,14 @@ fun CalendarHost(
if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) {
navigationVersion++
returnedFromDay = viewStack.last() == CalendarView.Day
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
// Set before the seek, which already composes the previous view.
returnedFromDay = viewStack.last() == CalendarView.Day
try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++
@@ -517,7 +611,24 @@ fun CalendarHost(
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
floatingActionButton = {
// While an event is selected the FAB is its action bar's toggle (#141).
val fabSelection = rememberFabSelection(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// A fresh event, like a duplicate from the detail screen (#52).
openImportForm(form, ImportSource.Insert)
},
chipShown = moveOutcome != null || selectionFailure != null,
onDismissChip = {
reschedule.consumeOutcome()
eventActions.consumeFailure()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
},
) { innerPadding ->
SharedTransitionLayout(
Modifier
@@ -548,6 +659,7 @@ fun CalendarHost(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateDraft = onCreateDraft,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
@@ -560,6 +672,7 @@ fun CalendarHost(
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
restorePosition = returnedFromDay,
)
}
}
@@ -571,7 +684,15 @@ fun CalendarHost(
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
)
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(host = chromeHost, viewModel = eventActions)
// 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.
@@ -608,8 +729,7 @@ fun CalendarHost(
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
// in-app new event (#52).
importFormSource = ImportSource.Insert
importForm = form
openImportForm(form, ImportSource.Insert)
detailKey = null
},
visible = detailKey != null,
@@ -627,10 +747,12 @@ fun CalendarHost(
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"),
)
}
}
@@ -651,7 +773,20 @@ fun CalendarHost(
detailKey = null
},
onManageCalendars = { showCalendars = true },
onDuplicate = { form ->
openImportForm(form, ImportSource.Insert)
},
// Opened straight from a tap (#178) there's no detail screen
// below to show why, so open it.
onLoadFailed = {
editKey = null
if (detailKey == null) {
heldKey = key
detailKey = key
}
},
visible = editKey != null,
viewModel = hiltViewModel(key = "edit"),
)
}
}
@@ -697,20 +832,24 @@ fun CalendarHost(
importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File
importForm = form
openImportForm(form, ImportSource.File)
},
)
}
importForm?.let { form ->
EventEditScreen(
initialDateIso = null,
initialForm = form,
initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
onManageCalendars = { showCalendars = true },
)
key(importFormId) {
EventEditScreen(
initialDateIso = null,
initialForm = form,
initialFormSource = importFormSource,
onClose = ::closeImportForm,
onSaved = ::closeImportForm,
onManageCalendars = { showCalendars = true },
leaveRequested = pendingInsert != null,
onLeaveDeclined = { pendingInsert = null },
viewModel = importViewModel,
)
}
}
// Declared last so it covers every overlay that can open it: Settings,
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted
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
@@ -78,4 +79,9 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val tapOpensEditorPref = prefs.tapOpensEditor
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
}
@@ -54,6 +54,7 @@ fun RootScreen(
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
@@ -155,6 +156,7 @@ fun RootScreen(
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
onExternalFormClosed = onExternalFormClosed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -39,6 +38,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
@@ -127,7 +127,7 @@ internal fun AgendaEventRow(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@@ -38,7 +37,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,7 +61,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
@@ -82,11 +90,23 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
restorePosition: Boolean = false,
viewModel: AgendaViewModel = hiltViewModel(),
) {
// Opens on the focused date and hands back the day scrolled to the top.
// Opens on today, not on the day another view was on (#367). Backing out of
// a day opened from here, or a recreated screen, returns to the day last at
// the top. The top day still goes back to the shared focus.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val opened = rememberSaveable { mutableStateOf(false) }
val restoreTop = remember(viewModel) {
if (restorePosition || opened.value) {
viewModel.lastTopDate
} else {
viewModel.goToToday()
null
}
}
SideEffect { opened.value = true }
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
@@ -106,20 +126,19 @@ fun AgendaScreen(
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
// Past days load above the anchor (#367), so the anchor's row counts as the
// start of the list.
val anchorIndex = remember { mutableIntStateOf(0) }
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
listState.firstVisibleItemIndex != anchorIndex.intValue ||
listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. 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.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today
// Off today, moving the anchor is what repositions the list; on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
@@ -188,11 +207,17 @@ fun AgendaScreen(
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
anchorIndex = anchorIndex,
initialTopDate = restoreTop,
onLoadEarlier = viewModel::loadEarlier,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
{
focus?.date = it
viewModel.rememberTopDay(it)
}
} else null,
modifier = Modifier
.weight(1f)
@@ -294,6 +319,9 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
initialTopDate: LocalDate? = null,
onLoadEarlier: () -> Unit = {},
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
@@ -308,28 +336,34 @@ internal fun AgendaContent(
// away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
// the agenda actually opens on today; a jumped-to date has no today
// in it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
// Past days alone don't count as anything coming up.
if (days.none { it.date >= state.anchor }) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
SideEffect { anchorIndex.intValue = 0 }
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
anchor = state.anchor,
anchorIndex = anchorIndex,
initialTopDate = initialTopDate,
onLoadEarlier = onLoadEarlier,
today = state.today,
zone = state.zone,
listState = listState,
@@ -349,6 +383,10 @@ internal fun AgendaContent(
@Composable
private fun AgendaList(
days: List<AgendaDay>,
anchor: LocalDate,
anchorIndex: MutableIntState,
initialTopDate: LocalDate?,
onLoadEarlier: () -> Unit,
today: LocalDate,
zone: TimeZone,
listState: LazyListState,
@@ -359,19 +397,49 @@ private fun AgendaList(
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
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.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
pendingRestore[0] = null
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
?.takeIf { it >= 0 }
?: anchorRow
listState.requestScrollToItem(index)
onDispose {}
}
// Scrolling up near the first loaded day loads earlier days.
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
val loadOnScrollUp = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
currentLoadEarlier()
}
return Offset.Zero
}
}
}
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentItemDates by rememberUpdatedState(itemDates)
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier,
modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
@@ -401,7 +469,7 @@ private fun AgendaList(
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
@@ -412,6 +480,9 @@ private fun AgendaList(
}
}
/** How close to the first loaded row an upward scroll starts loading earlier days. */
private const val LOAD_EARLIER_WITHIN = 10
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
@@ -93,22 +93,21 @@ fun groupAgendaDays(
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
* events, by inserting an empty-event [AgendaDay] in date order that the agenda
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
* when today already has its own day in [days]. Keeps today anchored so a glance
* tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
): List<AgendaDay> {
if (!enabled || days.any { it.date == today }) return days
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -118,8 +117,10 @@ sealed interface AgendaUiState {
data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState
data class Success(
/** First day of the loaded window (today, or a jumped-to date). */
/** The day the agenda opens on (today, or a jumped-to date). */
val anchor: LocalDate,
/** First loaded day, before [anchor] once past days are loaded (#367). */
val windowStart: LocalDate,
val today: LocalDate,
val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.catch
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
@@ -31,6 +32,7 @@ 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
@@ -88,8 +90,16 @@ class AgendaViewModel @Inject constructor(
private val todayDate: LocalDate
get() = currentDate()
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
// The anchor and how many days before it are loaded, moved together so a
// jump never queries a window that pairs the new anchor with the old span.
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
val anchor: StateFlow<LocalDate> = _window
.map { it.anchor }
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
var lastTopDate: LocalDate? = null
private set
private var lastToday = todayDate
@@ -101,7 +111,9 @@ class AgendaViewModel @Inject constructor(
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date && _anchor.value == old) _anchor.value = date
if (old != date && _window.value.anchor == old) {
_window.value = _window.value.copy(anchor = date)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
@@ -111,9 +123,17 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
combine(
_window,
agendaSettings,
_rangeOverride,
weekStart,
settingsPrefs.pastEventDisplay,
) { window, settings, override, weekStart, pastDisplay ->
AgendaParams(
anchor = anchor,
anchor = window.anchor,
// Hidden past events leave nothing to scroll back to (#367).
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
@@ -122,8 +142,8 @@ class AgendaViewModel @Inject constructor(
}
.flatMapLatest { params ->
val window = agendaRange(
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
params.windowStart,
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine(
@@ -143,12 +163,30 @@ class AgendaViewModel @Inject constructor(
)
fun goToToday() {
_anchor.value = todayDate
goToDate(todayDate)
}
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
if (_window.value.anchor == date) return
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
lastTopDate = null
}
/**
* 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.
*/
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)
}
fun rememberTopDay(date: LocalDate) {
lastTopDate = date
}
/** Temporarily override the agenda range for this session (the bottom-left pill). */
@@ -161,13 +199,20 @@ class AgendaViewModel @Inject constructor(
val showBar: Boolean,
)
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private data class AgendaParams(
val anchor: LocalDate,
val pastDays: Int,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
)
) {
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private fun buildState(
params: AgendaParams,
@@ -181,9 +226,10 @@ class AgendaViewModel @Inject constructor(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
return AgendaUiState.Success(
anchor = anchor,
windowStart = params.windowStart,
today = today,
days = days,
range = params.range,
@@ -195,6 +241,9 @@ class AgendaViewModel @Inject constructor(
}
}
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** 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)
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success(
anchor = today,
windowStart = today,
today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
@@ -6,7 +6,6 @@ import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -95,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -478,7 +478,7 @@ private fun CalendarNameField(
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
@@ -522,7 +522,7 @@ private fun CalendarNameField(
*/
@Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
@@ -278,12 +278,23 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
* grid lines follow it instantly — so the tween stands down for the gesture
* rather than leaving the blocks trailing the ruler they are measured against.
* A [landing] block, under a dropped copy, moves without the spring's bounce.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
fun animatedBlockPlacement(
xFraction: Float,
widthFraction: Float,
y: Dp,
height: Dp,
landing: Boolean = false,
): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val dpSpec: FiniteAnimationSpec<Dp> = when {
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
@@ -1,5 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
@@ -53,12 +66,37 @@ class ScreenActions(
val onCreate: () -> Unit,
)
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
* [create] is set for a block drafted on an empty slot (#153), which has no
* event yet, so the bar offers to create one.
*/
@Stable
class EventSelection(
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact by mutableStateOf(false)
internal set
var actions: ScreenActions? = null
private set
@@ -80,8 +118,51 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate)
val drafted = onCreate != null
val selection = remember(event, drafted) {
event?.let {
EventSelection(
event = it,
dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null,
)
}
}
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/**
* The top bar every calendar screen shares — or, while an event is selected,
* the selection's own bar in its place (#141).
*/
@Composable
fun CalendarTopBar(
host: ChromeHost,
@@ -91,6 +172,43 @@ fun CalendarTopBar(
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
AnimatedContent(
targetState = host.selection,
contentKey = { it != null },
transitionSpec = {
(fadeIn(effects) + slideInVertically { -it / 4 })
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
},
label = "top-bar",
) { selection ->
if (selection != null) {
SelectionTopBar(selection)
} else {
ViewTopBar(
host = host,
selectedView = selectedView,
quickSwitchViews = quickSwitchViews,
onCycleView = onCycleView,
onOpenDrawer = onOpenDrawer,
onOpenSearch = onOpenSearch,
showTodayButton = showTodayButton,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
@@ -136,16 +254,26 @@ fun CalendarTopBar(
)
}
/** The FAB column every calendar screen shares. */
/**
* The FAB column every calendar screen shares. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
AnimatedVisibility(
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection,
)
}
}
/**
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -14,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
Box(
modifier = modifier
@@ -1,11 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today.
* the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
selection: FabSelection? = null,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible,
visible = todayVisible && selection == null,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
// 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
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val label = stringResource(
when {
selection == null -> R.string.event_edit_new_title
selection.expanded -> R.string.event_selection_hide
else -> R.string.event_selection_actions
},
)
FloatingActionButton(
onClick = selection?.onToggle ?: onCreate,
modifier = Modifier.semantics { contentDescription = label },
) {
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
}
}
}
}
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
@Composable
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = 90f * swap
scaleX = 1f - 0.5f * swap
scaleY = 1f - 0.5f * swap
alpha = (1f - swap).coerceIn(0f, 1f)
},
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = turn - 90f * (1f - swap)
scaleX = 0.5f + 0.5f * swap
scaleY = 0.5f + 0.5f * swap
alpha = swap.coerceIn(0f, 1f)
},
)
}
}
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
@@ -67,9 +69,12 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
*
* [onTap] carries a field's existing tap action through, announced as
* [onTapLabel]; a field without one stays unclickable rather than growing a
* ripple that leads nowhere, and gets its long press announced through semantics instead. That branch merges the
* node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused.
* ripple that leads nowhere. The long press is announced through semantics,
* merging the node it sits on, so a screen reader lands on the field itself
* and finds the action there — a bare container is never focused.
*
* The press is caught ahead of the field's children, so a long press on a
* link inside the text copies too instead of leaving that stretch dead.
*/
@Composable
fun Modifier.copyOnLongPress(
@@ -80,27 +85,45 @@ fun Modifier.copyOnLongPress(
onTapLabel: String? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onClickLabel = onTapLabel,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
},
)
}.semantics(mergeDescendants = true) {
val haptics = LocalHapticFeedback.current
return longPressAheadOfChildren(label, text, copy) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
}
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
}
/**
* Run [onLongPress] once a press has held still for the long-press timeout,
* watching on the initial pass — before the children see the events — and
* then swallow the rest of that press, so neither a link under the finger nor
* a tap handler fires on release.
*/
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
pointerInput(*keys) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id }
val moved = change != null &&
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
if (change == null || !change.pressed || moved) break
}
true
}
if (endedEarly != null) return@awaitEachGesture
onLongPress()
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
} while (event.changes.any { it.pressed })
}
}
@@ -0,0 +1,203 @@
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
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.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
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
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.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** 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
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
): DeleteOutcome = try {
deleteEvent(eventId, beginMillis, scope)
DeleteOutcome.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteOutcome.NeedsPermission
} catch (e: NoSuchEventException) {
DeleteOutcome.Gone
} catch (e: Exception) {
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
DeleteOutcome.Failed
}
/**
* 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.
*/
@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 {
val context = LocalContext.current
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
pending = null
}
return { action, fallback ->
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,139 @@
package de.jeanlucmakiola.calendula.ui.common
import android.net.Uri
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.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
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 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
/** A delete waiting on its confirmation dialog. */
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
/** How an action on a selected event ended, when it has something to say. */
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
@HiltViewModel
class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(),
)
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
/** A create form copying [event], or null when it can't be read. */
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())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
suspend fun shareUri(event: EventInstance): Uri? {
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
return uri
}
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
fun requestDelete(event: EventInstance) {
viewModelScope.launch {
val detail = load(event) ?: return@launch
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
}
}
fun confirmDelete(scope: RecurringWriteScope) {
val prompt = _deletePrompt.value ?: return
_deletePrompt.value = null
viewModelScope.launch {
val event = prompt.event
_failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
DeleteOutcome.Deleted -> null
DeleteOutcome.Gone -> EventActionFailure.Gone
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
DeleteOutcome.Failed -> EventActionFailure.DeleteFailed
}
}
}
fun cancelDelete() {
_deletePrompt.value = null
}
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
fun reportShareFailed() {
_failure.value = EventActionFailure.ShareFailed
}
fun consumeFailure() {
_failure.value = null
}
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
suspend fun canModify(calendarId: Long): Boolean {
val calendar = calendars.value[calendarId] ?: try {
repository.calendars().first().firstOrNull { it.id == calendarId }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
return calendar?.canModifyContents == true
}
private suspend fun load(event: EventInstance): EventDetail? = try {
// The row holds the series start; the actions work on this occurrence.
repository.eventDetail(event.eventId)
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_failure.value = EventActionFailure.Gone
null
} catch (e: Exception) {
_failure.value = EventActionFailure.LoadFailed
null
}
}
@@ -24,38 +24,57 @@ import androidx.compose.ui.unit.dp
/** How far the finger may wander during the hold — well under touch slop. */
private val PICKUP_TOLERANCE = 6.dp
/** What a long press on a block turned into. */
enum class PickUp {
/** Nothing to pick up; the gesture is left alone. */
None,
/** Lifted and following the finger. */
Drag,
/** Held but not movable — the gesture is swallowed until the finger lifts. */
Hold,
}
/**
* Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* ancestor consumes or the finger leaves the block, which a
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* is deliberately not consumed, so a scroll starting on top of a block survives.
*
* [onRelease] is the long press that never moved: the finger lifted within touch
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
* release is a drop like any other. [immediate] also lifts the block as soon as
* the finger moves past touch slop, with no hold — for a block already selected.
*/
@Composable
fun rememberEventDragSource(
enabled: Boolean,
key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier = rememberDragSurface(
onRelease: (() -> Unit)? = null,
immediate: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
onPickUp(pointerInRoot, nodeInRoot, size)
true
},
immediate = immediate,
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
)
/**
* The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture.
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
* a press a descendant already consumed alone.
*/
@Composable
fun rememberDragSurface(
@@ -65,6 +84,33 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)? = null,
skipClaimedDown: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
immediate = false,
onPickUp = { local, pointer, node, size ->
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
skipClaimedDown = skipClaimedDown,
)
@Composable
private fun rememberPickUpSurface(
enabled: Boolean,
key: Any?,
immediate: Boolean,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
skipClaimedDown: Boolean = false,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -74,27 +120,44 @@ fun rememberDragSurface(
val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel)
val currentRelease by rememberUpdatedState(onRelease)
// Not a key: the pickup changes it, and a restart would cancel the drag.
val currentImmediate by rememberUpdatedState(immediate)
return Modifier
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// A child that took the press, such as a resize handle, keeps it.
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) {
while (true) {
val immediate = currentImmediate
// Null: held still. True: an immediate pickup past slop. False: abandoned.
val outcome = withTimeoutOrNull(holdMillis) {
var result: Boolean? = null
while (result == null) {
val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
if (main.changes.count { it.pressed } > 1) break
if ((change.position - origin).getDistance() > tolerance) break
// An ancestor's claim only becomes visible once the whole
// main pass has run, so look again on the final pass.
val final = awaitPointerEvent(PointerEventPass.Final)
if (final.changes.any { it.isConsumed }) break
val change = main.changes.firstOrNull { it.id == down.id }
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
result = when {
change == null || !change.pressed -> false
main.changes.count { it.pressed } > 1 -> false
immediate && distance > slop && !change.isConsumed -> {
change.consume()
true
}
!immediate && distance > tolerance -> false
// An ancestor's claim only becomes visible once the
// whole main pass has run, so look again on the final pass.
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
else -> null
}
}
} == null
if (!heldStill) return@awaitEachGesture
result
}
if (outcome == false) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp(
@@ -103,8 +166,11 @@ fun rememberDragSurface(
layout.positionInRoot(),
layout.size,
)
if (!took) return@awaitEachGesture
if (took == PickUp.None) return@awaitEachGesture
// Moved already, for an immediate pickup: it can't be a release.
var moved = outcome == true
var dropped = false
var released = false
try {
// Driven on the initial pass, which runs parent → child: an
// ancestor that outranks us (the pinch) has already consumed
@@ -116,18 +182,31 @@ fun rememberDragSurface(
if (change.isConsumed) break
if (!change.pressed) {
change.consume()
dropped = true
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
released = true
} else {
dropped = true
}
break
}
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (took == PickUp.Hold) continue
if ((change.position - origin).getDistance() > slop) moved = true
coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) }
}
} finally {
// Also reached when the pointer node is disposed mid-drag —
// a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel()
when {
released -> {
if (took == PickUp.Drag) currentCancel()
currentRelease?.invoke()
}
dropped -> currentDrop()
took == PickUp.Drag -> currentCancel()
}
}
}
}
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
* a pill on the FAB's band, leaving the calendar visible behind it.
*/
@Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
prompt?.let { pending ->
RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title),
title = stringResource(
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
),
onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly,
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
// A failed selection action (#141) shares the chip rather than stacking on it.
val message = when (outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
null -> actionFailure
is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
stringResource(R.string.event_move_done, movedLabel.orEmpty())
}
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome()
}
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
// write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight }
SnackChip(
visible = outcome != null,
visible = outcome != null || actionFailure != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -142,3 +163,7 @@ private fun formatMovedTo(
return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
}
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
return move.dragEnabled && move.allows(event)
}
/**
* Whether a long press may select events at all (#141) — the gesture setting,
* whatever the event's calendar allows. A read-only event is selected but stays put.
*/
@Composable
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved.
@@ -0,0 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
@Composable
fun EventSelectionBar(
canEdit: Boolean,
canDelete: Boolean,
onEdit: () -> Unit,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
// The FAB's height and colours, so the two read as one control.
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
}
}
}
/** A drafted block's bar (#153): create the event, or let the draft go. */
@Composable
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
}
}
}
@Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
@@ -0,0 +1,135 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.launch
/** The bottom band the selection bar and FAB cover, margin included. */
val SelectionBarClearance = FabSize + SnackChipMargin
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
@Composable
fun rememberFabSelection(
host: ChromeHost,
viewModel: EventActionsViewModel,
onEdit: (EventInstance) -> Unit,
onDuplicate: (EventForm) -> Unit,
chipShown: Boolean,
onDismissChip: () -> Unit,
): FabSelection? {
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.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
// Folded while a chip is up, which then sits beside the FAB as usual.
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
selection.create?.let { create ->
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
DraftSelectionBar(
onCreate = { currentWrite { create() } },
onDiscard = selection.dismiss,
)
},
)
}
}
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
val canDelete = canEdit && calendar?.isManaged == false
return remember(selection, expanded, chipShown, canEdit, canDelete) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
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)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
}
}
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@Composable
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
/** The delete confirmation a selected event's Delete brings up (#141). */
@Composable
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
deletePrompt?.let { prompt ->
DeleteEventDialog(
isRecurring = prompt.isRecurring,
onConfirm = { writeScope ->
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
viewModel.confirmDelete(writeScope)
},
onDismiss = viewModel::cancelDelete,
)
}
}
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
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
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */
@@ -67,7 +70,7 @@ fun HourGutter(
val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
derivedStateOf { dragController.drag?.markerMin }
}
val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
@@ -107,12 +110,25 @@ fun HourGutter(
}
}
}
dragStartMin?.let { startMin ->
// 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 badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = top)
.graphicsLayer {
alpha = badgeIn
scaleX = 0.8f + 0.2f * badgeIn
scaleY = 0.8f + 0.2f * badgeIn
}
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
@@ -121,7 +137,8 @@ fun HourGutter(
contentAlignment = Alignment.Center,
) {
Text(
text = formatGutterTime(startMin, use24Hour, locale),
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
@@ -195,15 +195,20 @@ class OutOfViewEdges internal constructor(
}
}
/** Whether a press at [y] (content px) lands on an edge bar (#153). */
fun covers(y: Float): Boolean = edgeAt(y) != null
private fun edgeAt(y: Float) = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
)
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
val edge = edgeAt(y) ?: return false
reveal(edge)
return true
}
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
@@ -16,6 +21,17 @@ import kotlin.time.Instant
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/**
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
* or on any day before the one [now] falls in, so a multi-day event still
* running doesn't stay undimmed on the days already behind it.
*/
fun EventInstance.isPastOn(
day: LocalDate,
now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(),
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
@@ -15,6 +16,9 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
@@ -54,6 +58,9 @@ sealed interface MoveTarget {
* disagree by a day over which zone the event's first day is resolved in.
*/
data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */
data class Span(val start: Instant, val end: Instant) : MoveTarget
}
/**
@@ -74,7 +81,7 @@ data class MoveRequest(
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed.
*/
data class MoveScopePrompt(val occurrenceOnly: Boolean)
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
@@ -88,12 +95,14 @@ data class MoveUndo(
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse.
* confirmation message, and [endMillis] where it ends — set only for a resize,
* whose message names both; [undo] is null when the write has no clean inverse.
*/
data class Moved(
val startMillis: Long,
val isAllDay: Boolean,
val undo: MoveUndo?,
val endMillis: Long? = null,
) : MoveOutcome
data object Undone : MoveOutcome
@@ -122,6 +131,7 @@ sealed interface MoveOutcome {
class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -226,7 +236,10 @@ class RescheduleViewModel @Inject constructor(
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
)
}
}
return true
@@ -308,18 +321,19 @@ class RescheduleViewModel @Inject constructor(
return null
}
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
}
// A zero-distance drop is not a write.
if (shifted == original) return null
// The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed
return null
}
@@ -396,6 +410,7 @@ class RescheduleViewModel @Inject constructor(
startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope),
endMillis = resizedEndMillis(prepared),
)
} catch (e: CancellationException) {
throw e
@@ -480,6 +495,13 @@ class RescheduleViewModel @Inject constructor(
null
}
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
@@ -489,8 +511,7 @@ class RescheduleViewModel @Inject constructor(
return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
form.startInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
}
}
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
/** The handle's touch target, centred on the edge it moves. */
val ResizeHandleTarget = 48.dp
private val HANDLE_DOT = 16.dp
private val HANDLE_RING = 2.dp
/**
* A grip on one edge of a selected event (#141), dragged without a hold. No
* semantics: TalkBack resizes through the event's edit action.
*/
@Composable
fun ResizeHandle(
color: Color,
enabled: Boolean,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentStart by rememberUpdatedState(onStart)
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
// Not a key: turning it off mid-resize must not cancel that resize.
val currentEnabled by rememberUpdatedState(enabled)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(ResizeHandleTarget)
.clearAndSetSemantics { }
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (!currentEnabled) return@awaitEachGesture
down.consume()
var started = false
var ended = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// Claimed by an ancestor that outranks us — the pinch.
if (change.isConsumed) break
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (!change.pressed) {
ended = started
break
}
val root = coordinates[0]?.takeIf { it.isAttached }
?.localToRoot(change.position) ?: break
if (started) {
currentMove(root)
} else {
started = true
currentStart(root)
}
}
} finally {
when {
ended -> currentEnd()
started -> currentCancel()
}
}
}
},
) {
Box(
Modifier
.size(HANDLE_DOT)
.background(color, CircleShape)
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.ZoneId
import java.util.Locale
import kotlin.time.toJavaInstant
/**
* The top bar while an event is selected (#141): says which, and when, with a
* way out — the selection otherwise only shows on the grid and in the FAB.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionTopBar(selection: EventSelection) {
val event = selection.event
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val whenText = remember(event.start, event.end, use24Hour, locale) {
formatSelectionWhen(event, use24Hour, locale)
}
TopAppBar(
navigationIcon = {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
),
)
}
},
title = {
Column {
Text(
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
// The header's own surface, as the view's bar has (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/**
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
*/
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
val zone = ZoneId.systemDefault()
val start = event.start.toJavaInstant().atZone(zone)
val end = event.end.toJavaInstant().atZone(zone)
val day = localizedDateFormatter(locale, "EEEdMMM")
val time = timeOfDayFormatter(use24Hour, locale)
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
return if (endsSameDay) {
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
} else {
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
}
}
@@ -1,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
import kotlinx.datetime.TimeZone
import java.time.LocalTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter
import java.util.Locale
@@ -68,3 +73,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
}
private const val MINUTES_PER_DAY = 1_440
/**
* [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after
* it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case
* where the time alone doesn't say which instant it is (#387).
*/
fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String {
if (!instant.inRepeatedHour(zone)) return formatted
val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
?: return formatted
return "$formatted $abbreviation"
}
@@ -1,11 +1,11 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -27,6 +28,7 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
@@ -35,14 +37,25 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.instantIn
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.domain.repicked
import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolveIn
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
import de.jeanlucmakiola.calendula.ui.week.wallClockMinutes
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
@@ -50,13 +63,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
@@ -97,11 +112,28 @@ data class TimelineDrag(
* drop lands, so it is dropped once the gesture ends ([finish]).
*/
val edgeOnly: Boolean = false,
/**
* The minute of its day the gutter badge names — the top edge, or for a
* bottom-edge resize the end being dragged (#141).
*/
val markerMin: Int = startMin,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
/**
* What the snap haptics key off: one tick per changed slot, not per frame.
* Carries the length, which is all a bottom-edge resize changes.
*/
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
}
/** The event a long press left selected (#141). */
data class TimelineSelection(
val event: EventInstance,
/** Set from a move or resize of it until the grid shows where it landed. */
val moved: Boolean = false,
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
val fromLift: Boolean = false,
)
/**
* One day column's share of a dragged event, ready to draw. Both halves of an
* event crossing midnight are previewed, whichever half the finger picked up:
@@ -248,6 +280,139 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
private const val DRAFT_ID = Long.MIN_VALUE
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
/** A drafted block's stand-in event, [minutes] long from [start]. */
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
instanceId = DRAFT_ID,
eventId = DRAFT_ID,
calendarId = DRAFT_ID,
title = title,
start = start,
end = start + minutes.minutes,
isAllDay = false,
color = color,
location = null,
)
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
internal fun draftStartMin(minutes: Float): Int =
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
// The end is exclusive, so a draft ending at midnight belongs to the day before.
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
if (end <= start) return null
val startMin = wallClockMinutes(start, date, zone)
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
}
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
* midnight. Only the held edge moves. The other keeps its own instant rather than
* a round trip through the grid, where an end in the fall-back hour's repeat is
* drawn to that hour's end, not at its own time (#387). In a repeated hour, the
* held edge takes the pass that matches the direction it was dragged from where
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger.
*/
internal fun resizedDrop(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): TimelineResizeDrop {
val drawn = wallClockSpan(event, zone)
return TimelineResizeDrop(
event = event,
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
zone,
shownAt = wallClockTime(drawn.day, drawn.startMin),
notAfter = event.end,
).instantIn(zone)
} else {
event.start
},
end = if (edge == ResizeEdge.Bottom) {
val picked = wallClockTime(date, startMin + spanMin)
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
picked.time.toSecondOfDay() / 60
event.end.toFormTime(zone).repicked(
picked,
zone,
shownAt = wallClockTime(drawn.day, drawn.endMin),
notBefore = event.start,
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
).instantIn(zone)
} else {
event.end
},
)
}
/**
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
* start day's midnight. Snapped, and at least one snap step long.
*/
internal fun resizedSpan(
edge: ResizeEdge,
startMin: Int,
endMin: Int,
edgeMin: Float,
edgeDay: Int? = null,
): Pair<Int, Int> {
// An edge dragged into a day past the other end changes nothing.
if (edgeDay != null) {
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val wrongWay = when (edge) {
ResizeEdge.Top -> edgeDay > endDay
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
}
if (wrongWay) return startMin to endMin
}
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + 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
}
/** How far past its column's side the finger has to go before a resize changes day. */
private const val COLUMN_HYSTERESIS = 0.25f
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
val under = position.toInt().coerceIn(0, lastIndex)
if (held == null) return under
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
return if (stays) held else under
}
/**
* The timeline's live geometry, republished on every layout. Plain fields rather
* than snapshot state: it changes on every scroll frame, and the drag loop reads
@@ -320,10 +485,92 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
selection = TimelineSelection(event, moved, fromLift)
}
fun deselect() {
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
selection = null
selectionCovered = false
}
/** The events of the blocks on screen that show the selection; none for a while ends it. */
val selectedCandidates = mutableStateListOf<EventInstance>()
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
}
/**
* Once a move of the selected event has settled, point the selection at the
* row it landed as. A new event id wins: a single moved occurrence is written
* as an exception, and another occurrence of its series may share its time.
*/
fun settleMovedSelection() {
val current = selection?.takeIf { it.moved } ?: return
if (isDragging || settling != null) return
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
// Only the original row left: the move was abandoned, and it still stands.
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
selection = current.copy(event = chosen, moved = false)
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
/**
* Whether [block] is the selected event: by instance, by series and start, and
* just after a move by title and times, since a moved occurrence gets new ids.
*/
fun isSelected(block: TimedBlock): Boolean {
val current = selection ?: return false
val selected = current.event
val event = block.event
return event.instanceId == selected.instanceId ||
(event.eventId == selected.eventId && event.start == selected.start) ||
(
current.moved &&
event.calendarId == selected.calendarId &&
event.title.isNotBlank() &&
event.title == selected.title &&
event.start == selected.start &&
event.end == selected.end
)
}
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
fun adoptSelection(block: TimedBlock) {
val current = selection?.takeIf { !it.moved } ?: return
if (current.event != block.event) selection = current.copy(event = block.event)
}
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
var pointerMoved: Boolean by mutableStateOf(false)
private set
/** Touch slop, past which the finger counts as having moved. */
var slopPx: Float = 0f
private var pickupPointer = Offset.Zero
/** The end a settling resize asked for, which the grid has to show before it settles. */
private var settledEnd: Instant? = null
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost
@@ -332,6 +579,10 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false)
private set
/** Whether the settling drop is the selected event, whose block lands still lifted. */
var settlingSelected: Boolean = false
private set
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
@@ -353,6 +604,10 @@ class TimelineDragController {
* finger is on; this is what turns where it lands back into an event start.
*/
private var clipOffsetMin = 0
private var dragZone: TimeZone? = null
private var dragDuration = Duration.ZERO
/** The dragged event's pass of a repeated hour, worked out once per drag (#387). */
private var dragKept: UtcOffset? = null
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
@@ -361,44 +616,93 @@ class TimelineDragController {
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/
private var originSlot: Pair<LocalDate, Int>? = null
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
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
// The selected event keeps its selection through a drag; any other ends it.
if (!isSelected(block)) deselect()
source = block
this.clipOffsetMin = clipOffsetMin
dragZone = zone
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null
recompute()
originSlot = drag?.slot
}
/**
* Draft a block on an empty slot and lift it at once (#153). It is selected
* from the start, so it keeps its handles and bar once the finger lets go.
*/
fun beginDraft(block: TimedBlock, zone: TimeZone, pointerInRoot: Offset, blockInRoot: Offset) {
deselect()
select(block.event)
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
recompute()
}
fun cancel() {
resizeEdge = null
resizeDay = null
resizeColumn = null
resizeSlot = null
source = null
clipOffsetMin = 0
dragZone = null
dragDuration = Duration.ZERO
dragKept = null
titleLines = 0
isDragging = false
pointerMoved = false
liftedInstanceId = null
originSlot = null
drag = null
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
settlingSelected = false
}
/**
@@ -429,8 +733,11 @@ class TimelineDragController {
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
(
block.event.eventId == landed.event.eventId ||
(
@@ -452,6 +759,9 @@ class TimelineDragController {
val clipOffset = clipOffsetMin
cancel()
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
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
// [noteGrid] can never seat it and it would sit at the top of today for
@@ -463,6 +773,141 @@ class TimelineDragController {
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
* Grab one edge of [block] to resize it (#141). The edge follows the finger
* across days as a drag does; the other stays where it is.
*/
fun beginResize(
block: TimedBlock,
edge: ResizeEdge,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
source = block
resizeEdge = edge
resizeDay = span.day
resizeStartMin = span.startMin
resizeEndMin = span.endMin
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
isDragging = true
pointerMoved = true
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 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
originSlot = null
recompute()
originSlot = drag?.slot
}
/**
* 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.
*/
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
val edge = resizeEdge
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
return drop
}
/**
* 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? {
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(
position = (pointer.x - origin.x) / columnPx,
held = resizeColumn,
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
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(edge: ResizeEdge) {
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 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()
val visible = -startIndex..days.lastIndex - startIndex
val slices = dragSlices(start, end - start, within = visible)
drag = TimelineDrag(
event = block.event,
date = day,
startMin = start,
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,
)
},
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
} else {
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
},
)
}
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (!block.isAttached) return
val port = viewport.boundsInRoot()
val bottom = block.positionInRoot().y + block.size.height + extraPx
val covered = bottom > port.bottom - clearancePx
if (covered != selectionCovered) selectionCovered = covered
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave
@@ -477,6 +922,8 @@ class TimelineDragController {
fun release() {
settling = null
settledOnGrid = false
settledEnd = null
settlingSelected = false
handOver()
}
@@ -486,6 +933,10 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
resizeEdge?.let {
recomputeResize(it)
return
}
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
@@ -496,13 +947,22 @@ 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 eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
.coerceAtLeast(0)
val floor = dragFloorMin(clipOffsetMin, eventSpan)
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventStartMin = startMin - clipOffsetMin
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 target = days[dayIndex]
val keep = dragKept
// Measured where it would land: the drop keeps the real duration, which in
// wall clock gains or loses the hour of a DST change it crosses (#387).
val floor = dragFloorMin(
clipOffsetMin,
wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep),
)
val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin
// A start in the spring-forward gap lands an hour on; show it there.
val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone)
val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep)
val visible = -dayIndex..days.lastIndex - dayIndex
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty {
@@ -577,6 +1037,7 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
@@ -589,10 +1050,7 @@ fun rememberTimelineDragController(): TimelineDragController {
* so the offset has to come back off at the drop (#253).
*/
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
val start = event.start.toLocalDateTime(zone)
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
}
/**
@@ -613,15 +1071,58 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
// Wall clock throughout, and past this day at either end: a block's top edge
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset
// from an instant — which would shift by an hour across a DST boundary.
val fromMidnight = startMin - clipOffsetMin
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
/**
* The wall-clock minutes an event lasting [duration] covers when it starts
* [startMin] past [date]'s midnight in [zone] — its real length, give or take a
* DST change in between (#387).
*/
internal fun wallClockSpanMinutes(
date: LocalDate,
startMin: Int,
duration: Duration,
zone: TimeZone,
kept: UtcOffset? = null,
): Int {
val start = wallClockInstant(date, startMin, zone, kept)
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
}
/**
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
* [kept] is its later one: an event there stays on it (#387). Pass
* [repeatedHourOffsetAt] of the event's time, which is null outside the hour —
* such an instant says nothing about which pass the grid slot means, so a drop
* never depends on the season the event came from.
*/
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant =
wallClockTime(date, minutes).resolveIn(zone, kept)
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
}
/** An event's span as wall-clock minutes from its start day's midnight. */
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
/**
* [event]'s [WallClockSpan], its end past 1440 when it ends on a later day
* (DST-safe). One ending in the fall-back hour's repeat ends where that hour does.
*/
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
val start = event.start.toLocalDateTime(zone)
val startMin = start.time.toSecondOfDay() / 60
return WallClockSpan(
start.date,
startMin,
wallClockEndMinutes(startMin, event.start, event.end, start.date, zone),
)
}
/**
@@ -651,7 +1152,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
@@ -660,8 +1161,10 @@ 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)
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
// 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) {
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
}
// Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the
@@ -693,7 +1196,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null
val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f,
// A selected event's block is lifted too: sinking first made it shrink, then grow back.
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
label = "drag-lift",
)
val copyAlpha by animateFloatAsState(
@@ -870,7 +1374,7 @@ private fun DragCopy(
Text(
text = label,
// As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -880,3 +1384,30 @@ private fun DragCopy(
}
}
}
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
@Composable
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val reduceMotion = rememberReduceMotion()
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val raise = remember { Animatable(if (selected) 1f else 0f) }
LaunchedEffect(selected) {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
// The effect runs a frame late, and that frame would show the block unlifted.
return if (selected && snap) 1f else raise.value
}
/**
* A selected block raised off the grid, the way a dragged copy is: scaled from
* its top edge so the text doesn't creep, with a shadow in the block's shape.
*/
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * raise
scaleY = 1f + 0.02f * raise
shadowElevation = 6.dp.toPx() * raise
this.shape = shape
clip = false
}
@@ -1,15 +1,10 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -41,7 +36,6 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -51,7 +45,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
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
@@ -67,17 +60,20 @@ 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.core.content.ContextCompat
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -90,13 +86,18 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
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.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.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
@@ -104,10 +105,10 @@ 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.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
@@ -162,43 +163,15 @@ fun EventDetailScreen(
val onShareClick = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
Unit
}
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action —
// edit, delete, or duplicate — held here until the result comes back.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
// upgrade in place and continues straight into the tapped action.
val requireWrite = rememberWriteGate()
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the
@@ -322,70 +295,6 @@ fun EventDetailScreen(
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun EventDetailContent(
state: EventDetailUiState.Success,
@@ -394,7 +303,7 @@ private fun EventDetailContent(
) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val locale = currentDetailLocale()
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
@@ -409,15 +318,26 @@ private fun EventDetailContent(
// cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) {
val titleLabel = stringResource(R.string.event_detail_title)
val titleDecoration = if (detail.status == EventStatus.Cancelled) {
TextDecoration.LineThrough
} else {
null
}
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
// A phone number in the title ("Call Anna 0151 …") dials too.
text = if (instance.title.isBlank()) {
AnnotatedString(stringResource(R.string.event_untitled))
} else {
linkify(
instance.title,
state.phoneLinks.title,
MaterialTheme.colorScheme.primary,
titleDecoration,
)
},
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (detail.status == EventStatus.Cancelled) {
TextDecoration.LineThrough
} else {
null
},
textDecoration = titleDecoration,
modifier = Modifier
.weight(1f)
.then(
@@ -464,7 +384,7 @@ private fun EventDetailContent(
val gap = 12.dp
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
@@ -504,7 +424,7 @@ private fun EventDetailContent(
// 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()
?.let { formatWhen(instance, it, locale) }
?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))
Text(
@@ -534,7 +454,8 @@ private fun EventDetailContent(
)
}
// Location (conditional). URLs open in the browser, the rest in maps.
// Location (conditional). URLs open in the browser, phone numbers in the
// dialer, the rest in maps.
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
@@ -544,7 +465,7 @@ private fun EventDetailContent(
iconContentDescription = locationLabel,
) {
Text(
text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
@@ -561,8 +482,9 @@ private fun EventDetailContent(
}
}
// Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
// Description (conditional). HTML is rendered (#271); URLs and phone
// numbers are auto-linked.
state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
@@ -571,10 +493,10 @@ private fun EventDetailContent(
// The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
modifier = Modifier.copyOnLongPress(descriptionLabel, description.copyText(), copyField),
) {
Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -847,45 +769,114 @@ private fun foreignTimeZone(
)
}
/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */
/**
* Wrap http(s) URLs in [text] as links that open in the browser, and its
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
* link's underline joins [textDecoration] rather than replacing it, so a
* cancelled title stays struck through across its links.
*/
@Composable
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
private fun linkify(
text: String,
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString = linkify(RichText(text), phoneLinks, linkColor, textDecoration)
/**
* [linkify] for text rendered from HTML: its own formatting and `<a href>`
* links come along, and a URL or number already inside one isn't linked again.
*/
@Composable
private fun linkify(
text: RichText,
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString {
val context = LocalContext.current
return remember(text, linkColor, context) {
val styles = TextLinkStyles(
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
)
return remember(text, phoneLinks, linkColor, textDecoration, context) {
// Decorations go on separately so overlapping ones add up: a span's
// textDecoration replaces whatever sits under it.
val styles = TextLinkStyles(style = SpanStyle(color = linkColor))
val decorations = mutableListOf<Triple<Int, Int, TextDecoration>>()
textDecoration?.let { decorations += Triple(0, text.text.length, it) }
for (range in text.styles) {
when (range.style) {
InlineStyle.Underline -> decorations += Triple(range.start, range.end, TextDecoration.Underline)
InlineStyle.Strikethrough -> decorations += Triple(range.start, range.end, TextDecoration.LineThrough)
else -> Unit
}
}
fun linked(start: Int, end: Int) = text.links.any { start < it.end && it.start < end }
buildAnnotatedString {
append(text)
for (link in findWebLinks(text)) {
addLink(
LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
link.start,
link.end,
)
append(text.text)
for (range in text.styles) range.style.toSpanStyle()?.let { addStyle(it, range.start, range.end) }
fun link(url: String, start: Int, end: Int, onClick: () -> Unit) {
addLink(LinkAnnotation.Url(url, styles) { onClick() }, start, end)
decorations += Triple(start, end, TextDecoration.Underline)
}
for (link in text.links) {
if (link.url.startsWith("tel:")) {
link(link.url, link.start, link.end) { dial(context, Uri.decode(link.url.substring(4))) }
} else {
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
}
for (link in findWebLinks(text.text)) {
if (linked(link.start, link.end)) continue
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
for (link in phoneLinks) {
if (linked(link.start, link.end)) continue
link("tel:${link.number}", link.start, link.end) { dial(context, link.number) }
}
val bounds = decorations.flatMap { listOf(it.first, it.second) }.distinct().sorted()
for ((start, end) in bounds.zipWithNext()) {
val covering = decorations.filter { it.first <= start && end <= it.second }.map { it.third }.distinct()
if (covering.isNotEmpty()) addStyle(SpanStyle(textDecoration = TextDecoration.combine(covering)), start, end)
}
}
}
}
private fun InlineStyle.toSpanStyle(): SpanStyle? = when (this) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
InlineStyle.Superscript -> SpanStyle(baselineShift = BaselineShift.Superscript, fontSize = 0.75.em)
InlineStyle.Subscript -> SpanStyle(baselineShift = BaselineShift.Subscript, fontSize = 0.75.em)
InlineStyle.Monospace -> SpanStyle(fontFamily = FontFamily.Monospace)
InlineStyle.Underline, InlineStyle.Strikethrough -> null
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a
* single primary line spanning both ends.
*/
@Composable
private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair<String, String?> {
val use24Hour = LocalUse24HourFormat.current
val allDayLabel = stringResource(R.string.event_detail_all_day)
// Remembered: a time in the repeated hour looks up its zone abbreviation (#387).
return remember(instance, zone, locale, use24Hour, allDayLabel) {
formatWhen(instance, zone, locale, use24Hour, allDayLabel)
}
}
private fun formatWhen(
instance: EventInstance,
zone: TimeZone,
locale: Locale,
use24Hour: Boolean,
allDayLabel: String,
): Pair<String, String?> {
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
val timeShort = timeOfDayFormatter(use24Hour, locale)
val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day events live at UTC midnights with an exclusive end. Resolve
@@ -905,12 +896,13 @@ private fun formatWhen(
}
val endLdt = instance.end.toJavaLocalDateTime(zid)
val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale)
val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale)
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
} else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
"$start – $end" to null
}
}
@@ -920,7 +912,15 @@ private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
/** Open [url] in a browsable app, or say so when none can take it. */
private fun openWebLink(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
}
/** Open the dialer with [number] filled in, or say so when none can take it. */
private fun dial(context: Context, number: String) {
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
}
private fun startOrSayNoApp(context: Context, intent: Intent) {
if (runCatching { context.startActivity(intent) }.isFailure) {
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
/**
* UI state for the event-detail screen (spec S4).
@@ -21,6 +22,10 @@ sealed interface EventDetailUiState {
* the sync would just resurrect it. Contact dates are removed at the source.
*/
val isManaged: Boolean = false,
/** Phone numbers to link in the title, location and description. */
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
) : EventDetailUiState
}
@@ -11,12 +11,14 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.descriptionRichText
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +34,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
/**
@@ -112,22 +113,11 @@ class EventDetailViewModel @Inject constructor(
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
when (scope) {
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteUiState.NeedsPermission
} catch (e: Exception) {
DeleteUiState.Failed
_deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
// Already gone is what a delete wants anyway.
DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
DeleteOutcome.Failed -> DeleteUiState.Failed
}
}
}
@@ -144,66 +134,46 @@ class EventDetailViewModel @Inject constructor(
*/
suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching {
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
return withContext(io) { icsExporter.stageEventShare(detail) }
}
/**
* Build a create form seeded from the open event so the user can save a
* copy as a new, independent event (#52). Returns null when nothing is
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
* A create form seeded from the open event, to save a copy as a new event
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly. An external
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
// own DTSTART/DTEND instead of overriding it to the epoch.
// An external "open event" that names no occurrence ([NO_OCCURRENCE_TIME] —
// e.g. a bare content://.../events/<id> VIEW intent, issue #48) keeps the
// row's own DTSTART/DTEND instead of overriding it to the epoch.
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
detail
} else {
detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
detail.atOccurrence(target.beginMillis, target.endMillis)
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
EventDetailUiState.Success(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true,
// Found here, off the main thread and once per load: the platform
// matcher loads its metadata on first use.
phoneLinks = findEventPhoneLinks(
title = corrected.instance.title,
location = corrected.instance.location,
description = description?.text,
),
description = description,
)
} catch (e: CancellationException) {
throw e
@@ -228,13 +198,3 @@ class EventDetailViewModel @Inject constructor(
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
}
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,202 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
/**
* A phone number found in free text, spanning [start] until [end] (exclusive).
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
* dial-in suffix — the separators that only made it readable are gone.
*/
data class PhoneLink(val start: Int, val end: Int, val number: String)
/** The phone numbers in an event's free-text fields, each against its own text. */
data class EventPhoneLinks(
val title: List<PhoneLink> = emptyList(),
val location: List<PhoneLink> = emptyList(),
val description: List<PhoneLink> = emptyList(),
)
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
internal data class PhoneMatch(val start: Int, val end: Int)
// A conference dial-in's pauses and meeting id right after the number:
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
// Whitespace as invites write it: Java's \s misses the no-break space and the
// invisible direction marks Meet and Outlook wrap around numbers.
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
// A number right after one of these labels is an id to type in, not one to
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
// "Conference number" is left out: invites use it for the dial-in itself.
private val notAPhoneLabelRegex = Regex(
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
RegexOption.IGNORE_CASE,
)
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
// three-digit lead is one only beside a currency — "612 345 678" and
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
// A short group set off by a space after a long one, with a word straight
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
// it is the number's own.
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
// often a postcode, house number or booking reference.
private const val MIN_DIGITS = 7
/**
* Find the phone numbers in [text]. The platform's libphonenumber-backed
* matcher does the finding — it knows the device's region and real number
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
* Call off the main thread: the matcher loads its metadata on first use.
* No match at all when the platform matcher fails.
*/
internal fun findPhoneLinks(text: String): List<PhoneLink> =
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
/** [findPhoneLinks] for each of an event's free-text fields. */
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
title = findPhoneLinks(title),
location = location?.let(::findPhoneLinks).orEmpty(),
description = description?.let(::findPhoneLinks).orEmpty(),
)
// The matcher reads the region off the SIM, which throws on a device without
// telephony; a field then just goes without phone links.
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
val spannable = SpannableString(text)
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
spannable.getSpans(0, spannable.length, URLSpan::class.java)
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
}.getOrDefault(emptyList())
/**
* Turn the platform's [matches] in [text] into dialable links. The matcher is
* lenient, so a match is dropped when it sits inside one of the [webLinks],
* follows a meeting id or PIN label, holds a date, reads as an amount or is
* too short. What's kept loses a stray count off its end, regains an
* international "+49 (0)" prefix the matcher left behind, and carries a
* conference dial-in suffix along.
*/
internal fun refinePhoneMatches(
text: String,
matches: List<PhoneMatch>,
webLinks: List<WebLink>,
): List<PhoneLink> {
val links = mutableListOf<PhoneLink>()
for (match in matches.sortedBy { it.start }) {
var start = match.start
var end = match.end
val matched = text.substring(start, end)
if (webLinks.any { start < it.end && it.start < end }) continue
if (gluedIntoAWord(text, start, end)) continue
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
if (dateRegex.findAll(matched).any { it.isDate() }) continue
if (isDottedTimes(matched)) continue
if (isAmount(text, start, end)) continue
strayTailRegex.find(matched)?.let { tail ->
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
}
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
// tail, which would dial without the country code.
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
?.let { start -= it.value.length }
val number = dialable(text.substring(start, end))
if (number.count { it.isDigit() } < MIN_DIGITS) continue
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
// A suffix swallowed what the matcher saw as the next number.
if (links.lastOrNull()?.let { start < it.end } == true) continue
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
}
return links
}
// How far around a match the label, prefix, currency and suffix checks look.
// Enough for any of them, and it keeps a long invite from being re-read whole
// for every number in it.
private const val LOOK_BEHIND = 40
private const val LOOK_AHEAD = 60
private fun String.window(from: Int, to: Int): String =
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
/**
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
* its https:// — which no web link covered. A phone number stands apart.
*/
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
val before = text.getOrNull(start - 1)
val after = text.getOrNull(end)
return (before != null && (before.isLetter() || before in "/=?&")) ||
(after != null && (after.isLetter() || after == '/'))
}
private fun isDottedTimes(matched: String): Boolean {
val times = dottedTimesRegex.matchEntire(matched) ?: return false
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
}
}
private fun isAmount(text: String, start: Int, end: Int): Boolean {
val matched = text.substring(start, end)
if (shortLeadAmountRegex.matches(matched)) return true
return amountRegex.matches(matched) &&
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
}
private fun MatchResult.isDate(): Boolean {
val (a, _, b, c) = destructured
val first = a.toInt()
val second = b.toInt()
val last = c.toInt()
return when {
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
a.length > 2 -> false
// Day first (most of the world) or month first (US).
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
}
}
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
/**
* Strip [raw] down to what a dialer takes. An international number's "(0)"
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
* call, so it goes too; an extension becomes a pause.
*/
private fun dialable(raw: String): String {
val international = raw.trimStart('(').startsWith("+")
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
return withoutTrunk.replace(extensionRegex, ",")
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
}
@@ -4,7 +4,6 @@ import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
@@ -13,7 +12,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -36,6 +34,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
@@ -106,7 +115,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -116,10 +124,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.isPinnedAwayFrom
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
@@ -162,6 +173,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
@@ -174,7 +186,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime
import kotlin.time.toJavaInstant
@@ -184,6 +195,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
@@ -192,6 +204,10 @@ import kotlin.time.Clock
* first asks how far the change reaches (this occurrence / this and
* following / whole series) — then closes. The calendar picker offers only
* writable calendars; when editing, the calendar is fixed.
*
* Leaving with unsaved changes asks to save, discard or keep editing (#178).
* While editing, the overflow menu offers Duplicate (the form as it stands,
* handed to [onDuplicate] as a new event), Share and Delete.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -201,13 +217,21 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialDraft: LongArray? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
onDuplicate: ((EventForm) -> Unit)? = null,
onDeleted: () -> Unit = onSaved,
onLoadFailed: () -> Unit = onClose,
leaveRequested: Boolean = false,
onLeaveDeclined: () -> Unit = {},
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
// Keyed on every input that decides the form, so a second draft or slot on
// the same day opens its own form even when the composition is kept alive.
LaunchedEffect(initialDateIso, editKey, initialForm, initialDraft, initialStartMinutes) {
when {
// A prefilled open: a single-event .ics for review, or an external
// ACTION_INSERT intent. The source drives how reminders are seeded.
@@ -217,9 +241,14 @@ fun EventEditScreen(
beginMillis = editKey[1],
endMillis = editKey[2],
)
// A block drafted on the timeline (#153): its own start and end.
initialDraft != null -> viewModel.openDraft(
start = Instant.fromEpochMilliseconds(initialDraft[0]),
end = Instant.fromEpochMilliseconds(initialDraft[1]),
)
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
?: Clock.System.now().toLocalDateTime(viewModel.zone.value).date
viewModel.openNew(date, initialStartMinutes)
}
}
@@ -235,41 +264,91 @@ fun EventEditScreen(
viewModel.reset()
onClose()
}
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// A running save or delete can't be called back, so leaving waits for it;
// its result then closes the form or reports why it didn't.
val requestClose = {
when {
state?.saveState == SaveUiState.Saving -> Unit
hasUnsavedChanges -> showLeavePrompt = true
else -> close()
}
}
// Something else wants this form's place, e.g. a second share (#306).
LaunchedEffect(leaveRequested) {
if (leaveRequested) requestClose()
}
// The event vanished before the form could load it — fall back to the
// detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
if (loadFailed) close()
if (loadFailed) {
viewModel.reset()
onLoadFailed()
}
}
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Contextual WRITE_CALENDAR upgrade for v1.0 installs, like delete.
val writePermissionLauncher = rememberLauncherForActivityResult(
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
// Save keeps its own launcher: its result calls straight into the ViewModel,
// so a save waiting on the permission dialog survives a rotation.
val savePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) viewModel.save()
}
val onSaveClick = {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
if (context.canWriteCalendar()) {
viewModel.save()
} else {
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
savePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val shareFailedMessage = stringResource(R.string.event_share_failed)
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
val onShareClick: () -> Unit = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
}
// The copy opens over this form; a form with nothing to lose steps aside.
val onDuplicateClick: () -> Unit = {
requireWrite {
viewModel.duplicateForm()?.let { form ->
onDuplicate?.invoke(form)
if (!hasUnsavedChanges) close()
}
}
}
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val deleteDeniedMessage = stringResource(R.string.event_delete_write_denied)
LaunchedEffect(state?.saveState) {
when (state?.saveState) {
SaveUiState.Saved -> {
viewModel.reset()
onSaved()
}
SaveUiState.Deleted -> {
viewModel.reset()
onDeleted()
}
SaveUiState.DeleteFailed -> {
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(deleteFailedMessage, duration = SnackbarDuration.Long)
}
SaveUiState.DeleteDenied -> {
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(deleteDeniedMessage, duration = SnackbarDuration.Long)
}
// A failed save leaves the form looking unchanged, so the snackbar is
// the only sign anything happened — long rather than the default
// flash (Codeberg #234: it read as "nothing happens at all").
@@ -287,7 +366,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
modifier = Modifier.predictiveBack(onBack = requestClose, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -295,7 +374,7 @@ fun EventEditScreen(
// matching the detail screen's bare top bar.
title = {},
navigationIcon = {
IconButton(onClick = close) {
IconButton(onClick = requestClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
@@ -303,6 +382,15 @@ fun EventEditScreen(
}
},
actions = {
if (state?.isEditing == true) {
EditActionsMenu(
canDelete = state?.isManaged == false,
canDuplicate = onDuplicate != null,
onDuplicate = onDuplicateClick,
onShare = onShareClick,
onDelete = { requireWrite { showDeleteDialog = true } },
)
}
Button(
onClick = onSaveClick,
enabled = state != null && state?.saveState != SaveUiState.Saving,
@@ -329,6 +417,35 @@ fun EventEditScreen(
}
}
if (showLeavePrompt) {
UnsavedChangesDialog(
onSave = {
showLeavePrompt = false
onSaveClick()
},
onDiscard = {
showLeavePrompt = false
close()
},
onDismiss = {
showLeavePrompt = false
if (leaveRequested) onLeaveDeclined()
},
)
}
val recurs = viewModel.editedEventRecurs()
if (showDeleteDialog && recurs != null) {
DeleteEventDialog(
isRecurring = recurs,
onConfirm = { deleteScope ->
showDeleteDialog = false
viewModel.delete(deleteScope)
},
onDismiss = { showDeleteDialog = false },
)
}
if (state?.saveState == SaveUiState.AwaitingScope) {
SaveScopeDialog(
recurrenceChanged = state?.recurrenceChanged == true,
@@ -373,6 +490,90 @@ fun EventEditScreen(
}
}
/** Duplicate, Share and Delete for the event being edited, behind an overflow button. */
@Composable
private fun EditActionsMenu(
canDelete: Boolean,
canDuplicate: Boolean,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.event_edit_more_actions),
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
if (canDuplicate) {
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_duplicate)) },
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
onClick = {
open = false
onDuplicate()
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_share)) },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
onClick = {
open = false
onShare()
},
)
// Managed special-dates events have no delete, as on the detail screen.
if (canDelete) {
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_delete)) },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
onClick = {
open = false
onDelete()
},
)
}
}
}
}
/**
* Asked when leaving the form with unsaved changes (#178). Dismissing keeps
* editing; a form with nothing changed closes without asking.
*/
@Composable
private fun UnsavedChangesDialog(
onSave: () -> Unit,
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_unsaved_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_unsaved_save),
onClick = onSave,
)
OptionCard(
label = stringResource(R.string.event_edit_unsaved_discard),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.event_edit_unsaved_keep))
}
},
)
}
/**
* Offer to apply the settings default reminder to an event opened from a `.ics`
* file. The file's own reminders are kept unless the user accepts. A plain
@@ -501,8 +702,9 @@ private fun EventEditContent(
) {
val form = state.form
val locale = currentLocale()
val deviceZone by viewModel.zone.collectAsStateWithLifecycle()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
// The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
@@ -662,9 +864,14 @@ private fun EventEditContent(
},
) {
Spacer(Modifier.height(4.dp))
val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) }
val startAt = remember(form.start, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, formZone) { form.endInstant(formZone) }
ScheduleRow(
label = stringResource(R.string.event_edit_starts),
dateTime = form.start,
dateTime = form.start.dateTime,
instant = startAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
@@ -673,7 +880,9 @@ private fun EventEditContent(
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
dateTime = form.end,
dateTime = form.end.dateTime,
instant = endAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.EndDate },
@@ -692,20 +901,20 @@ private fun EventEditContent(
// The rows above edit a pinned event in *its own* zone, which is the
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent (null) unless
// the event is pinned somewhere other than here.
// leaving the user to do the offset arithmetic. Absent unless the
// event is pinned somewhere other than here.
// Keyed rather than recomputed: this sits in the same Column as the
// title field, so it would otherwise re-parse the zone on every
// keystroke.
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
form.timesIn(TimeZone.currentSystemDefault())
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
form.isPinnedAwayFrom(deviceZone)
}
localTimes?.let { (localStart, localEnd) ->
if (pinnedAway) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale),
formatTimeRange(startAt, endAt, deviceZone, locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -769,7 +978,7 @@ private fun EventEditContent(
timeZoneOptionOf(
id,
locale,
at = form.start.toInstant(zone).toJavaInstant(),
at = form.startInstant(zone).toJavaInstant(),
regionOf = ::icuTimeZoneRegion,
)
}
@@ -2091,7 +2300,7 @@ private fun ColorPickerDialog(
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
FullScreenPicker(
title = stringResource(R.string.event_edit_color),
onDismiss = onDismiss,
@@ -2251,18 +2460,23 @@ private fun EditCard(
}
/**
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
* time when both land on the same minute (an instant event shouldn't read as a
* range against itself). Dates are deliberately absent — the rows above carry
* them.
* "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses
* to one time for an instant event, which shouldn't read as a range against
* itself. Dates are deliberately absent — the rows above carry them.
*/
@Composable
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
val from = timeFormat.format(start.time.toJavaLocalTime())
val to = timeFormat.format(end.time.toJavaLocalTime())
return if (from == to) from else "$from – $to"
return remember(start, end, zone, locale, use24Hour) {
val timeFormat = timeOfDayFormatter(use24Hour, locale)
fun format(at: Instant) = withRepeatedHourZone(
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
at,
zone,
locale,
)
if (start == end) format(start) else "${format(start)} – ${format(end)}"
}
}
/**
@@ -2344,6 +2558,8 @@ private fun firstLineHeight(
private fun ScheduleRow(
label: String,
dateTime: LocalDateTime,
instant: Instant,
zone: TimeZone,
isAllDay: Boolean,
locale: Locale,
onDateClick: () -> Unit,
@@ -2385,8 +2601,11 @@ private fun ScheduleRow(
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
val time = remember(dateTime, instant, zone, locale, timeFormat) {
withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale)
}
Text(
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
text = time,
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
@@ -77,6 +77,11 @@ sealed interface SaveUiState {
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** The event was deleted from the form (#178). */
data object Deleted : SaveUiState
data object DeleteFailed : SaveUiState
/** WRITE_CALENDAR was revoked before a delete from the form. */
data object DeleteDenied : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : SaveUiState
data object Failed : SaveUiState
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -7,8 +8,10 @@ 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
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
@@ -18,14 +21,28 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
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
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.withEnd
import de.jeanlucmakiola.calendula.domain.withStart
import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.withTimezone
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -132,9 +149,15 @@ class EventEditViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
/** The zone the form's unpinned times are read in, for the screen to show them in too. */
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
// Problems stay hidden until the first save attempt, so a half-filled
@@ -156,6 +179,11 @@ class EventEditViewModel @Inject constructor(
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
// The form as the user received it: the loaded event, or a new form with its
// prefill and auto-applied defaults. Anything else is an unsaved change.
private val _baseline = MutableStateFlow<EventForm?>(null)
// Bumped by reset(), so work started for a closed form never lands on the next.
private var session = 0
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
@@ -176,6 +204,8 @@ class EventEditViewModel @Inject constructor(
val beginMillis: Long,
val endMillis: Long,
val zone: TimeZone,
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
val detail: EventDetail,
) {
val original: EventForm get() = snapshot.form
}
@@ -289,7 +319,11 @@ class EventEditViewModel @Inject constructor(
EventEditUiState(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
problems = if (local.showProblems) {
resolved.problems(zoneSource.current())
} else {
emptySet()
},
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (offerableFields - visibleFields).sorted(),
@@ -311,6 +345,14 @@ class EventEditViewModel @Inject constructor(
initialValue = null,
)
/**
* Whether leaving now would lose something the user did (#178): the form
* differs from how it opened, counting the defaults it applied itself.
*/
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _baseline) { form, baseline ->
form != null && form != baseline
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* First day of the week for ordering the recurrence weekday toggles — the
* app's "week starts on" preference, matching the calendar views (Auto
@@ -329,22 +371,32 @@ class EventEditViewModel @Inject constructor(
*/
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val zone = zoneSource.current()
val now = Clock.System.now()
val start = when {
val startAt = when {
startMinutes != null ->
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
date == now.toLocalDateTime(zone).date -> {
// Today: the next full hour (may roll into tomorrow before midnight).
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
}
else -> LocalDateTime(date, LocalTime(9, 0))
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
date == now.toLocalDateTime(zone).date -> nextFullHour(now)
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
}
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
open(EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone)))
applyDefaultReminder()
applyDefaultDuration()
}
/**
* Initialise a fresh form for a block drafted on the timeline (#153), from
* [start] to [end]. These are the instants the block shows, so a time in the
* fall-back hour's repeat, or one moved out of the spring-forward gap, opens
* as drawn (#387). The user chose the length, so the default length doesn't
* apply. No-op when a form is already open, as [openNew].
*/
fun openDraft(start: Instant, end: Instant) {
if (_form.value != null) return
val zone = zoneSource.current()
open(EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone)))
_durationTouched.value = true
applyDefaultReminder()
applyDefaultDuration()
}
@@ -377,7 +429,7 @@ class EventEditViewModel @Inject constructor(
fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
_form.value = form
open(form)
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
@@ -402,16 +454,18 @@ class EventEditViewModel @Inject constructor(
*/
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
val opened = session
viewModelScope.launch {
val defaults = reminderDefaults()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
if (opened != session) return@launch
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
// Write through update(): the duration default resolves in parallel,
// so both must compose onto the current form, not a pre-suspend copy.
update { it.copy(reminders = reminders) }
// The duration default resolves in parallel, so both must compose
// onto the current form, not a pre-suspend copy.
applyDefault { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
@@ -440,6 +494,7 @@ class EventEditViewModel @Inject constructor(
*/
private fun applyDefaultDuration(calendarId: Long? = null) {
if (_editTarget.value != null || _durationTouched.value) return
val opened = session
viewModelScope.launch {
val global = settingsPrefs.defaultEventDurationMinutes.first()
val overrides = settingsPrefs.perCalendarEventDuration.first()
@@ -451,14 +506,14 @@ class EventEditViewModel @Inject constructor(
else -> calendarId ?: resolvedCalendarId.first()
}
// Re-check after suspending: bail if the form closed or the user edited.
if (opened != session) return@launch
if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
val zone = TimeZone.currentSystemDefault()
update { form ->
form.copy(
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
)
applyDefault { form ->
val zone = form.resolvedZone(zoneSource.current())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
}
}
}
@@ -471,10 +526,12 @@ class EventEditViewModel @Inject constructor(
* configured and it isn't already exactly what the file carried.
*/
private fun maybePromptImportedReminderDefault(form: EventForm) {
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
if (opened != session) return@launch
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
@@ -490,7 +547,7 @@ class EventEditViewModel @Inject constructor(
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
update { it.copy(reminders = prompt.defaultReminders) }
edit { it.copy(reminders = prompt.defaultReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
@@ -510,31 +567,35 @@ class EventEditViewModel @Inject constructor(
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
val opened = session
viewModelScope.launch {
val detail = try {
repository.eventDetail(eventId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_loadFailed.value = true
if (opened == session) _loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
if (opened != session) return@launch
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
open(snapshot.form)
}
}
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
session++
_form.value = null
_baseline.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
@@ -553,24 +614,28 @@ class EventEditViewModel @Inject constructor(
// The title field wraps (multi-line) so long titles stay visible (#33), but
// a title is one logical line: drop any newline the IME's Enter key or a
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) = update {
fun setTitle(value: String) = edit {
val cleaned = value.replace("\n", "").replace("\r", "")
it.copy(title = capLength(cleaned, it.title))
}
// The location wraps too (#273) but is likewise one logical line. A pasted
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) = update {
fun setLocation(value: String) = edit {
val cleaned = value.replace(LINE_BREAK, ", ")
it.copy(location = capLength(cleaned, it.location))
}
fun setDescription(value: String) = update { it.copy(description = value) }
fun setDescription(value: String) = edit { it.copy(description = value) }
fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that
// the provider stores UTC-anchored, so a zone could only misrepresent
// them. Coming back out of all-day leaves the event on the device zone,
// which is the same thing a fresh event gets.
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
// A kept pass of a repeated hour stays, so switching back out of all-day
// lands where the event was — unless dropping the pin changed the zone.
edit {
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
}
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
@@ -585,7 +650,7 @@ class EventEditViewModel @Inject constructor(
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
update { it.copy(timezone = zoneId) }
edit { it.withTimezone(zoneId, zoneSource.current()) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
@@ -597,35 +662,35 @@ class EventEditViewModel @Inject constructor(
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) }
edit { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder and
// length unless the user has already hand-edited them (guarded inside).
applyDefaultReminder(id)
applyDefaultDuration(id)
}
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
/** Clear the colour so the event inherits its calendar's. */
fun clearColor() = update { it.copy(colorKey = null, color = null) }
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
fun addReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = it.reminders - minutes) }
edit { it.copy(reminders = it.reminders - minutes) }
}
/**
@@ -636,7 +701,7 @@ class EventEditViewModel @Inject constructor(
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
val trimmed = email.trim()
if (trimmed.isEmpty()) return
update { form ->
edit { form ->
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
form.copy(attendees = rest + attendee)
@@ -644,10 +709,10 @@ class EventEditViewModel @Inject constructor(
}
fun removeAttendee(attendee: EventAttendee) =
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
edit { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
/** Flip a guest between required and optional (the per-row role toggle). */
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = edit { form ->
form.copy(
attendees = form.attendees.map { a ->
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
@@ -663,12 +728,12 @@ class EventEditViewModel @Inject constructor(
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(date, it.end.time)) }
moveEnd { LocalDateTime(date, it.time) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(it.end.date, time)) }
moveEnd { LocalDateTime(it.date, time) }
}
/**
@@ -681,7 +746,7 @@ class EventEditViewModel @Inject constructor(
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems().isNotEmpty()) {
if (form.problems(zoneSource.current()).isNotEmpty()) {
_showProblems.value = true
return
}
@@ -726,8 +791,13 @@ class EventEditViewModel @Inject constructor(
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
val opened = session
// A form closed mid-save drops the result rather than handing it to the next one.
fun settle(result: SaveUiState) {
if (opened == session) _saveState.value = result
}
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
settle(SaveUiState.Saving)
// No locking (plan 03, decision 5): right before writing, re-read
// the event and compare against what the form loaded. An external
// change parks the save in a conflict dialog instead of silently
@@ -739,18 +809,18 @@ class EventEditViewModel @Inject constructor(
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = SaveUiState.Gone
settle(SaveUiState.Gone)
return@launch
} catch (e: Exception) {
// Can't verify — proceed; a real problem fails the write itself.
null
}
if (fresh != null && fresh != target.snapshot) {
_saveState.value = SaveUiState.AwaitingConflict(scope)
settle(SaveUiState.AwaitingConflict(scope))
return@launch
}
}
_saveState.value = try {
val result = try {
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
@@ -801,6 +871,37 @@ class EventEditViewModel @Inject constructor(
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed
}
settle(result)
}
}
/** Whether the event being edited recurs; null while not editing. */
fun editedEventRecurs(): Boolean? = _editTarget.value?.let { !it.detail.rrule.isNullOrBlank() }
/** The form as it stands, as a fresh one-off event — Duplicate in the edit form (#178). */
fun duplicateForm(): EventForm? = state.value?.form?.copy(rrule = null)
/** A staged `.ics` of the event as saved, or null when it can't be built. */
suspend fun shareUri(): Uri? {
val detail = _editTarget.value?.detail ?: return null
return withContext(io) { icsExporter.stageEventShare(detail) }
}
/** Delete the event being edited, as far as [scope] reaches; result lands in [saveState]. */
fun delete(scope: RecurringWriteScope) {
val target = _editTarget.value ?: return
if (_saveState.value == SaveUiState.Saving) return
val opened = session
_saveState.value = SaveUiState.Saving
viewModelScope.launch {
val outcome = repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)
if (opened != session) return@launch
_saveState.value = when (outcome) {
// Already gone is what a delete wants anyway.
DeleteOutcome.Deleted, DeleteOutcome.Gone -> SaveUiState.Deleted
DeleteOutcome.NeedsPermission -> SaveUiState.DeleteDenied
DeleteOutcome.Failed -> SaveUiState.DeleteFailed
}
}
}
@@ -809,15 +910,34 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Idle
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault()
val newStart = transform(form.start)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
form.copy(start = newStart, end = newEnd)
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
form.withStart(transform(form.start.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.start)
}
private inline fun update(block: (EventForm) -> EventForm) {
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
form.withEnd(transform(form.end.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.end)
}
/**
* The event as it was opened, while its times still mean the same instants:
* picking one of them again keeps its pass of a repeated hour (#387).
*/
private fun opened(form: EventForm): EventForm? =
_editTarget.value?.original?.takeIf { it.timezone == form.timezone && !it.isAllDay }
private fun open(form: EventForm) {
_form.value = form
_baseline.value = form
}
/** A default the form applied itself; it moves the baseline too, so it's no unsaved change. */
private inline fun applyDefault(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
_baseline.value = _baseline.value?.let(block)
}
/** A change the user made. */
private inline fun edit(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
}
}
@@ -24,6 +24,7 @@ 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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -46,7 +47,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -171,6 +171,7 @@ 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
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
@@ -588,7 +589,7 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@Composable
private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion()
@@ -1721,7 +1722,7 @@ internal fun SplitMonthGrid(
onSelectDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val month = state.month
val hasToday = state.today.year == month.year && state.today.month == month.month
val ripple = rememberTodayRipple(active = hasToday)
@@ -1940,7 +1941,7 @@ private fun SplitDots(
modifier = Modifier
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.alpha(if (dimCutoff != null && event.isPastOn(date, dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(event.color, dark, soften), CircleShape),
)
}
@@ -1954,7 +1955,7 @@ private fun SplitDots(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.morphBounds(MonthMorphKey.Overflow(date))
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
)
}
}
@@ -1997,7 +1998,7 @@ private fun SplitMonthAgendaPane(
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
@@ -2066,7 +2067,7 @@ internal fun SplitDayPane(
day = day,
zone = zone,
position = positionOf(index, dayEvents.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
onClick = { onEventClick(event) },
// An event added to or removed from the day you are
// already looking at moves its neighbours rather
@@ -2230,7 +2231,7 @@ private fun MonthWeekRow(
*/
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) }
val ripple = rememberTodayRipple(active = todayCol != null)
val overflowRow = rememberOverflowRowHeight()
@@ -2542,6 +2543,7 @@ private fun MonthWeekRow(
}
OverflowDots(
events = hiddenEvents,
day = d,
total = hidden,
dark = dark,
rowHeight = overflowRow,
@@ -2866,7 +2868,9 @@ private fun MonthBar(
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
// A bar is one piece, so it dims once the last day it covers has passed.
val dimmed = dimCutoff != null &&
(event.hasEnded(dimCutoff) || days?.let { event.isPastOn(it.last(), dimCutoff) } == true)
val paint = eventPaint(event, dark)
// The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half.
@@ -2919,6 +2923,7 @@ private fun MonthBar(
@Composable
private fun OverflowDots(
events: List<EventInstance>,
day: LocalDate,
total: Int,
dark: Boolean,
rowHeight: Dp,
@@ -2937,7 +2942,7 @@ private fun OverflowDots(
Box(
modifier = Modifier
.size(OVERFLOW_DOT_SIZE)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.alpha(if (allEnded(byColor.getValue(argb), day, dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(argb, dark, soften), CircleShape),
)
}
@@ -2947,15 +2952,15 @@ private fun OverflowDots(
text = "+$extra",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
modifier = Modifier.alpha(if (allEnded(events, day, dimCutoff)) EventDimAlpha else 1f),
)
}
}
}
/** True when dimming is on and every one of [events] is already over. */
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
/** True when dimming is on and every one of [events] is already over on [day]. */
private fun allEnded(events: List<EventInstance>, day: LocalDate, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) }
@Composable
private fun MonthGridLoading() {
@@ -8,7 +8,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -83,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -513,7 +513,7 @@ private fun SearchResultRow(
// Spans are empty for a blank title — the query never matches the
// placeholder — so marking the substitute is safe.
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
// On a picked row the headline is already recoloured for the secondary
// container, so the match is carried by weight alone there.
@@ -215,13 +215,13 @@ internal fun AppearanceScreen(
selected = state.themeMode,
label = { themeLabel(it) },
summary = { mode ->
if (mode == ThemeMode.SYSTEM) {
stringResource(
when (mode) {
ThemeMode.SYSTEM -> stringResource(
R.string.settings_theme_system_summary,
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
)
} else {
null
ThemeMode.BLACK -> stringResource(R.string.settings_theme_black_summary)
ThemeMode.LIGHT, ThemeMode.DARK -> null
}
},
onSelect = viewModel::setThemeMode,
@@ -258,6 +258,7 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
ThemeMode.BLACK -> R.string.settings_theme_black
},
)
@@ -47,6 +47,8 @@ data class SettingsUiState(
val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
val dragToReschedule: Boolean = true,
/** Whether tapping an event opens the edit form instead of the detail screen (#178). */
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
@@ -148,13 +148,16 @@ class SettingsViewModel @Inject constructor(
prefs.softenCalendarColors,
combine(
prefs.todayButtonInToolbar,
prefs.dragToReschedule,
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
prefs.multiDayCount,
::Triple,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
hourLines, weekNumbers, showToday, soften, todayInToolbar,
dragToReschedule = gestures.first,
tapOpensEditor = gestures.second,
multiDayCount = multiDay,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
@@ -166,6 +169,7 @@ class SettingsViewModel @Inject constructor(
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
tapOpensEditor = toggles.tapOpensEditor,
multiDayCount = toggles.multiDayCount,
)
},
@@ -202,6 +206,7 @@ class SettingsViewModel @Inject constructor(
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
tapOpensEditor = views.tapOpensEditor,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
@@ -305,6 +310,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
@@ -315,6 +321,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
@@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
}
fun setTapOpensEditor(enabled: Boolean) {
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
}
/**
* Switch the launcher label (#44). The card highlights at once, then settles
* on what the component state reports; the writes are binder round-trips and
@@ -74,6 +74,7 @@ internal fun ViewsScreen(
var showDefaultView by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showTapOpens by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
@@ -125,7 +126,7 @@ internal fun ViewsScreen(
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Middle,
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
@@ -134,6 +135,17 @@ internal fun ViewsScreen(
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(8.dp))
// How events respond to a tap and a long press (#178, #141).
SectionHeader(stringResource(R.string.settings_events_header))
GroupedRow(
title = stringResource(R.string.settings_tap_opens),
summary = tapOpensLabel(state.tapOpensEditor),
position = Position.Top,
onClick = { showTapOpens = true },
)
GroupedRow(
title = stringResource(R.string.settings_drag_to_reschedule),
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
@@ -387,6 +399,18 @@ internal fun ViewsScreen(
onDismiss = { showTimeFormat = false },
)
}
if (showTapOpens) {
OptionPicker(
title = stringResource(R.string.settings_tap_opens),
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
predictiveBack = true,
options = listOf(false, true),
selected = state.tapOpensEditor,
label = { tapOpensLabel(it) },
onSelect = viewModel::setTapOpensEditor,
onDismiss = { showTapOpens = false },
)
}
if (showPastEvents) {
PastEventsPicker(
selected = state.pastEventDisplay,
@@ -522,6 +546,11 @@ internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun tapOpensLabel(editor: Boolean): String = stringResource(
if (editor) R.string.settings_tap_opens_editor else R.string.settings_tap_opens_details,
)
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) {
@@ -16,13 +16,15 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
*
* The mechanics live in floret-kit's identity module; the look stays here, so a
* sibling app reuses the same theming without inheriting Calendula's palette.
* The Settings screen overrides darkTheme, dynamicColor and typography; the bare
* defaults (used by the crash screen) just follow the system.
* The Settings screen overrides darkTheme, dynamicColor, pureBlack (the
* AMOLED theme, #181) and typography; the bare defaults (used by the crash
* screen) just follow the system.
*/
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
typography: Typography = CalendulaTypography,
content: @Composable () -> Unit,
) {
@@ -31,6 +33,7 @@ fun CalendulaTheme(
darkScheme = CalendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = typography,
content = content,
)
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.ui.theme
import android.content.Context
import androidx.annotation.ColorRes
import androidx.core.content.edit
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
/**
* A synchronous copy of the theme choice, for the window backdrop drawn before
* the first Compose frame. The real setting lives in DataStore, which only
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
* override flashed the night-qualifier backdrop on a cold start or a
* recreation (#181). MainActivity mirrors every stored value here and reads it
* back before setContent.
*/
internal object WindowThemeCache {
private const val FILE = "window_theme"
private const val KEY_THEME_MODE = "theme_mode"
/** The last mirrored theme choice, or null before the first mirror. */
fun read(context: Context): ThemeMode? =
context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
.getString(KEY_THEME_MODE, null)
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
fun write(context: Context, mode: ThemeMode) {
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
}
}
/**
* The window backdrop matching this theme choice's Compose background, or null
* for [ThemeMode.SYSTEM], whose night-qualified theme default already matches.
*/
@ColorRes
internal fun ThemeMode.windowBackgroundRes(): Int? = when (this) {
ThemeMode.SYSTEM -> null
ThemeMode.LIGHT -> R.color.window_background_light
ThemeMode.DARK -> R.color.window_background_dark
ThemeMode.BLACK -> R.color.window_background_black
}
@@ -20,12 +20,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -52,6 +52,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
@@ -59,11 +60,37 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.activity.compose.BackHandler
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.toArgb
import de.jeanlucmakiola.calendula.ui.common.draftBlock
import de.jeanlucmakiola.calendula.ui.common.draftEvent
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
import de.jeanlucmakiola.calendula.ui.common.isDraft
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
import de.jeanlucmakiola.calendula.ui.common.selectionLift
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.StayPut
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.withFrameNanos
@@ -73,12 +100,14 @@ import androidx.compose.runtime.SideEffect
import de.jeanlucmakiola.calendula.ui.common.laneSlot
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
import kotlin.math.roundToInt
import kotlin.time.Instant
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
@@ -98,7 +127,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
@@ -135,6 +163,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -221,6 +250,7 @@ fun TimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
modifier: Modifier = Modifier,
weekViewModel: WeekViewModel = hiltViewModel(),
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
@@ -235,6 +265,7 @@ fun TimelineScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateDraft = onCreateDraft,
modifier = modifier,
)
}
@@ -281,6 +312,12 @@ internal val CalendarView.timelineKind: TimelineKind
else -> TimelineKind.Week
}
/**
* The zone the shown timeline is laid out in, so its blocks, drags and drops all
* read the same wall clock (#387).
*/
internal val LocalTimelineZone = staticCompositionLocalOf { TimeZone.currentSystemDefault() }
/** How long a switch waits for the view it goes to before resizing into it without its events. */
private const val MORPH_DATA_WAIT_MS = 150L
@@ -292,6 +329,7 @@ private fun PagedTimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
modifier: Modifier,
) {
// The view on screen, which trails [view] while the columns resize into it.
@@ -324,6 +362,7 @@ private fun PagedTimelineScreen(
val grid = position.grid
val anchorPage = position.page
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() }
val draftMinutes by key(viewModel) { viewModel.defaultEventDuration.collectAsStateWithLifecycle() }
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
@@ -417,7 +456,8 @@ private fun PagedTimelineScreen(
// Off the ticking clock rather than the loaded page, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val zone = remember { TimeZone.currentSystemDefault() }
// The timeline's own zone, so "today" agrees with the blocks and drops (#387).
val zone by viewModel.zone.collectAsStateWithLifecycle()
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
@@ -458,7 +498,10 @@ private fun PagedTimelineScreen(
)
ScreenBody(modifier = modifier) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalTimelineZone provides zone,
) {
WeekContent(
state = state,
today = today,
@@ -472,6 +515,8 @@ private fun PagedTimelineScreen(
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateDraft = onCreateDraft,
draftMinutes = draftMinutes,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -494,6 +539,8 @@ private fun WeekContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onCreateDraft: (Instant, Instant) -> Unit,
draftMinutes: Int,
modifier: Modifier = Modifier,
) {
val fadeSpec = rememberCalendarFadeSpec()
@@ -508,19 +555,95 @@ private fun WeekContent(
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
val dragController = rememberTimelineDragController()
SideEffect { dragController.draftMinutes = draftMinutes }
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
val zone = LocalTimelineZone.current
val onDrop: (TimelineDrop) -> Unit = drop@{ drop ->
val start = drop.startInstant(zone)
val moved = drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start))
// A draft has nothing to write, so it stays selected where it landed (#153).
if (drop.event.isDraft) {
dragController.select(moved)
return@drop
}
// A selected event stays selected where it landed (#141).
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
dragController.select(moved, moved = true)
}
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
target = MoveTarget.Start(start),
),
)
}
// A resize keeps its event selected, at its new length (#141).
val onResize: (TimelineResizeDrop) -> Unit = resize@{ drop ->
val resized = drop.event.copy(start = drop.start, end = drop.end)
if (drop.event.isDraft) {
dragController.select(resized)
return@resize
}
dragController.select(resized, moved = true)
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Span(drop.start, drop.end),
),
)
}
// A drafted block (#153) opens the new-event form at its own instants: the
// end it is drawn to differs on a DST change (#387).
val createDraft: (EventInstance) -> Unit = { draft ->
dragController.deselect()
onCreateDraft(draft.start, draft.end)
}
// Selection (#141) ends on back, a page or view change, or any tap.
val selection = dragController.selection
PublishSelection(
event = selection?.event,
busy = dragController.isDragging,
compact = dragController.selectionCovered,
onDismiss = dragController::deselect,
onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } },
)
BackHandler(enabled = selection != null) { dragController.deselect() }
LaunchedEffect(pagerState, dragController) {
snapshotFlow { pagerState.currentPage }.drop(1).collect {
// Deselecting would delete a draft mid-drag, so it stays until it lands.
val draftInFlight = dragController.isDragging && dragController.selection?.event?.isDraft == true
if (!draftInFlight) dragController.deselect()
}
}
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
dragController.settleMovedSelection()
}
val orphaned = selection != null && dragController.selectedCandidates.isEmpty() &&
!dragController.isDragging && dragController.settling == null
LaunchedEffect(orphaned) {
if (!orphaned) return@LaunchedEffect
// A beat for a re-read to bring the event back under a new row.
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
val requireWrite = rememberWriteGate()
val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) {
requireWrite { createDraft(event) }
} else {
dragController.deselect()
onEventClick(event)
}
}
Box(modifier = modifier) {
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
// Loading and failure concern the provider as a whole, so they stand in
@@ -541,10 +664,11 @@ private fun WeekContent(
today = today,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
} else WeekPager(
pagerState = pagerState,
@@ -555,10 +679,11 @@ private fun WeekContent(
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -585,6 +710,7 @@ private fun WeekPager(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
@@ -633,6 +759,7 @@ private fun WeekPager(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -667,6 +794,7 @@ private fun TimelineMorphFrame(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fromState by morph.fromState.collectAsStateWithLifecycle()
val toState by morph.toState.collectAsStateWithLifecycle()
@@ -722,6 +850,7 @@ private fun TimelineMorphFrame(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -815,6 +944,7 @@ internal fun WeekSuccess(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
WeekFrame(
weekStart = state.weekStart,
@@ -834,6 +964,7 @@ internal fun WeekSuccess(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -856,6 +987,7 @@ private fun WeekPage(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
@@ -875,6 +1007,7 @@ private fun WeekPage(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
@@ -898,6 +1031,7 @@ private fun TimelinePageContent(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -928,6 +1062,7 @@ private fun TimelinePageContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -1053,7 +1188,7 @@ private fun AllDayStrip(
animatedHeight: (() -> Dp)? = null,
onEventClick: (EventInstance) -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
Row(
modifier = Modifier
@@ -1076,6 +1211,7 @@ private fun AllDayStrip(
key(bar.event.eventId, bar.event.start) {
AllDayBar(
event = bar.event,
lastDay = bar.toRange.endInclusive,
dark = dark,
onClick = { onEventClick(bar.event) },
modifier = Modifier
@@ -1111,13 +1247,15 @@ private fun AllDayStrip(
@Composable
private fun AllDayBar(
event: EventInstance,
lastDay: LocalDate,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
// A bar is one piece, so it dims once the last day it covers here has passed.
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
val paint = eventPaint(event, dark)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
@@ -1154,9 +1292,10 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -1251,6 +1390,7 @@ private fun Timeline(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
scrollState = scrollState,
modifier = Modifier.fillMaxHeight(),
)
@@ -1273,6 +1413,7 @@ private fun DayColumnCard(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
scrollState: ScrollState,
modifier: Modifier = Modifier,
) {
@@ -1280,10 +1421,16 @@ private fun DayColumnCard(
val hourPx = with(density) { hourHeight.toPx() }
val morphing = LocalViewMorph.current != null
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
val zone = LocalTimelineZone.current
// A block drafted on an empty slot (#153), drawn over the part of it on this day.
val draft by remember(dragController) {
derivedStateOf { dragController.selection?.event?.takeIf { it.isDraft } }
}
val draftPiece = remember(draft, date, zone) { draft?.let { draftBlock(it, date, zone) } }
val shown = remember(blocks, draftPiece) { if (draftPiece == null) blocks else blocks + draftPiece }
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
LaunchedEffect(shown, dragController.settling) {
dragController.noteGrid(date, shown)
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
@@ -1293,6 +1440,7 @@ private fun DayColumnCard(
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1321,20 +1469,27 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (dragController.selection != null) {
dragController.deselect()
return@detectTapGestures
}
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
}
// Inside the tap detector, which consumes every down, so only a
// block or handle on top can claim the press first.
.then(draftSurface),
) {
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
shown.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f)
@@ -1345,6 +1500,7 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghosts(block),
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
@@ -1380,6 +1536,7 @@ private fun DayColumnCard(
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
onResize = onResize,
modifier = Modifier
.laneSlot(place)
.offset(y = place.y + topInset)
@@ -1424,13 +1581,16 @@ private fun EventBlock(
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zone = LocalTimelineZone.current
val labelEndMin = remember(block, date, zone) { block.labelEndMin(date, zone) }
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
minToHm(labelEndMin, use24Hour, locale)
val density = LocalDensity.current
val metrics = rememberBlockTextMetrics(height)
// Only full-width (non-overlapping) blocks that are tall enough show the
@@ -1444,7 +1604,7 @@ private fun EventBlock(
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val shownTime = rememberBlockTimeRange(
minToHm(block.startMin, use24Hour, locale),
minToHm(block.endMin, use24Hour, locale),
minToHm(labelEndMin, use24Hour, locale),
textWidth,
)
// A short block drops the title rather than serving a horizontally sliced
@@ -1474,72 +1634,237 @@ private fun EventBlock(
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
val draft = block.event.isDraft
val draggable = draft || eventDragAllowed(block.event)
val selectable = eventSelectAllowed()
val selected = dragController.isSelected(block)
val haptics = LocalHapticFeedback.current
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
// Long press (#141): move to drag, let go to select; selected blocks drag at once.
val dragModifier = rememberEventDragSource(
enabled = draggable,
enabled = selectable,
key = block.event.instanceId,
immediate = selected && draggable,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
if (draggable) {
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
zone = zone,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
PickUp.Drag
} else {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
dragController.select(block.event)
PickUp.Hold
}
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
// Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
val ghost = when {
takesOverLift && !lifted -> 1f
// A draft leaves no ghost behind, and the copy it lands under is identical.
draft -> if (lifted) 0f else 1f
else -> ghostFade
}
val raise = selectionLift(selected, snap = takesOverLift)
// Tells the action bar to fold when it would cover the bottom handle.
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
val barClearancePx = with(density) { SelectionBarClearance.toPx() }
val tracksBar = selected && !cuts.bottom
// Not while its own write is pending: it still shows the old times.
LaunchedEffect(selected, block.event, lifted) {
if (selected && !lifted) dragController.adoptSelection(block)
}
// Kept composed while hidden: removing them would cancel their own resize.
DisposableEffect(selected, block.event) {
if (selected) dragController.noteSelectedBlock(block.event, true)
onDispose { if (selected) dragController.noteSelectedBlock(block.event, false) }
}
val showHandles = selected && draggable
val handlesShown = !lifted && !dragController.isDragging
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
.zIndex(if (selected) 1f else 0f)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
.then(
if (tracksBar) {
Modifier.onGloballyPositioned {
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
}
} else {
Modifier
},
),
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
Box(
modifier = Modifier
.fillMaxSize()
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
}
if (showHandles) {
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
val handleLines = if (showTitle) titleMaxLines else 0
val begin = { edge: ResizeEdge ->
{ pointer: Offset ->
dragController.beginResize(block, edge, zone, handleLines, pointer)
}
}
val end = { dragController.finishResize(zone)?.let(onResize) }
// No bounce: an overshoot reads as the block's ends jumping.
val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "handles",
)
val handleAlpha = Modifier.graphicsLayer {
alpha = handleIn.coerceIn(0f, 1f)
scaleX = 0.5f + 0.5f * handleIn
scaleY = 0.5f + 0.5f * handleIn
}
if (!cuts.top) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Top),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
if (!cuts.bottom) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Bottom),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
}
}
}
/**
* A long press on an empty stretch of [date]'s column (#153): drafts a block of
* the default length from the slot under the finger and lifts it, as a long
* press on an event does. Presses on a block or handle are left to them.
*/
@Composable
private fun rememberDraftSurface(
date: LocalDate,
edges: OutOfViewEdges,
hourPx: Float,
dragController: TimelineDragController,
onDrop: (TimelineDrop) -> Unit,
): Modifier {
val currentDrop by rememberUpdatedState(onDrop)
val title = stringResource(R.string.event_edit_new_title)
val color = MaterialTheme.colorScheme.primary.toArgb()
val zone = LocalTimelineZone.current
val currentEdges by rememberUpdatedState(edges)
// The draft this gesture lifted, selected again on release after the cancel.
val lifted = remember { arrayOfNulls<EventInstance>(1) }
val currentHourPx by rememberUpdatedState(hourPx)
return rememberDragSurface(
enabled = eventSelectAllowed(),
key = date,
skipClaimedDown = true,
// A press on a block or handle was consumed by it and never gets here.
onPickUp = { local, pointer, node, _ ->
val hour = currentHourPx
if (hour <= 0f || currentEdges.covers(local.y)) return@rememberDragSurface false
val startMin = draftStartMin(local.y / hour * 60f)
val event = draftEvent(
title = title,
color = color,
start = wallClockInstant(date, startMin, zone),
minutes = dragController.draftMinutes,
)
val block = draftBlock(event, date, zone) ?: return@rememberDragSurface false
lifted[0] = event
dragController.beginDraft(
block = block,
zone = zone,
pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour),
)
true
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(currentDrop) },
// An abandoned pickup leaves no draft behind; a release selects it again below.
onCancel = {
dragController.cancel()
if (dragController.selection?.event?.isDraft == true) dragController.deselect()
},
onRelease = { lifted[0]?.let { dragController.select(it, fromLift = true) } },
)
}
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
/** How far in from the block's side a resize handle's centre sits. */
private val HANDLE_INSET = 16.dp
private val SKELETON_HEADER_HEIGHT = 36.dp
private val SKELETON_HEADER_PADDING = 8.dp
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -34,7 +35,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.FixedOffsetTimeZone
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
@@ -44,6 +48,8 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Instant
import kotlin.time.toKotlinDuration
import kotlin.time.toKotlinInstant
import javax.inject.Inject
import kotlin.math.abs
@@ -76,12 +82,18 @@ abstract class PagedTimelineViewModel(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
/**
* The zone every page is laid out in; drags and drops resolve in it too, and
* pages lay out again when the device moves zones (#387).
*/
val zone: StateFlow<TimeZone> = zoneSource.zone
.stateIn(viewModelScope, SharingStarted.Eagerly, zoneSource.current())
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -91,6 +103,14 @@ abstract class PagedTimelineViewModel(
initialValue = false,
)
/** How long a block drafted on an empty slot starts out (#153). */
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
)
private val todayDate: LocalDate
get() = currentDate()
@@ -152,12 +172,14 @@ abstract class PagedTimelineViewModel(
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
},
) { span ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today)
zone.flatMapLatest { zone ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today, zone)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
@@ -192,6 +214,7 @@ abstract class PagedTimelineViewModel(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
zone: TimeZone,
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
@@ -213,11 +236,13 @@ class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
@@ -237,11 +262,13 @@ class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
@@ -255,11 +282,13 @@ class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
zoneSource: ZoneSource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
@@ -331,6 +360,64 @@ internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRang
return from..to
}
/**
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
* negative before that day, past 1440 after it. Elapsed time since midnight would
* put everything after a DST change an hour off on that day's grid (#387).
*/
internal fun Instant.minutesPast(day: LocalDate, zone: TimeZone): Int {
val local = toLocalDateTime(zone)
val days = (local.date.toEpochDays() - day.toEpochDays()).toInt()
return days * MINUTES_PER_DAY + local.time.toSecondOfDay() / 60
}
/** [instant]'s [minutesPast] clamped to [day]. */
internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone): Int =
instant.minutesPast(day, zone).coerceIn(0, MINUTES_PER_DAY)
/**
* Where a run from [start] to [end] that begins at [startMin] ends on [day]'s
* wall clock. A run that began before the fall-back hour's repeat and ends
* inside it is drawn to where that hour ends (03:00 in Berlin), since its own
* wall-clock end reads earlier than its real length (#387).
*/
internal fun wallClockEndMinutes(
startMin: Int,
start: Instant,
end: Instant,
day: LocalDate,
zone: TimeZone,
): Int {
val wall = end.minutesPast(day, zone)
if (end <= start) return maxOf(wall, startMin)
val drawn = repeatedHourEndMinutes(start, end, day, zone) ?: wall
return if (drawn > startMin) drawn else startMin + (end - start).inWholeMinutes.toInt()
}
/**
* Where the fall-back hour's repeat [end] falls in ends, as minutes past [day]'s
* midnight: the wall clock the zone fell back from (03:00 in Berlin). Null unless
* [end] is in that repeat and [start] before it.
*/
private fun repeatedHourEndMinutes(start: Instant, end: Instant, day: LocalDate, zone: TimeZone): Int? {
// Just past [end], so an end on the transition itself still finds it.
val after = java.time.Instant.ofEpochMilli(end.toEpochMilliseconds() + 1)
val transition = zone.toJavaZoneId().rules.previousTransition(after)
?.takeIf { it.isOverlap } ?: return null
val fellBack = transition.instant.toKotlinInstant()
if (start >= fellBack || end >= fellBack + transition.duration.abs().toKotlinDuration()) return null
// The moment it fell back, read on the clock it fell back from.
return fellBack.minutesPast(day, FixedOffsetTimeZone(transition.offsetBefore.toKotlinUtcOffset()))
}
/**
* The end a block's time label names on [day]: its event's own wall-clock end,
* which for one ending in the fall-back hour's repeat is earlier than the
* [TimedBlock.endMin] it is drawn to (#387).
*/
internal fun TimedBlock.labelEndMin(day: LocalDate, zone: TimeZone): Int =
minOf(endMin, wallClockMinutes(event.end, day, zone))
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
if (isAllDay) {
@@ -384,14 +471,14 @@ internal fun layoutDay(
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val m = wallClockMinutes(ev.start, day, zone)
Raw(ev, m, m)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
val startMin = wallClockMinutes(s, day, zone)
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
}
}
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
)
}
}
+2 -28
View File
@@ -302,7 +302,7 @@
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
<string name="settings_month_view_style">نمط عرض الشهر</string>
<string name="month_style_paged">صفحات</string>
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر، أو اسحب للأعلى لعرض جدول الشهر.</string>
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
<string name="month_style_split">تقسيم</string>
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
@@ -732,7 +732,7 @@
<string name="event_detail_title">العنوان</string>
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
<string name="settings_licences_subtitle">المشاريع التي بنيت ‌‌Calendula عليها</string>
<string name="licences_title">التراخيص مفتوحة المصدر</string>
<string name="licences_title">تراخيص مفتوحة المصدر</string>
<string name="licences_intro">يتضمن Calendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
<string name="licences_footer">يتم تضمين كل مشروع هنا دون تعديل، بموجب الترخيص الموضح.</string>
<string name="state_failure_permission_body">يقرأ Calendula أحداثك مباشرةً من تقويم النظام، لذلك فأنه يحتاج للوصول إلى التقويم لعرض أي شيء.</string>
@@ -778,30 +778,4 @@
<string name="color_name_amber">أصفر كهرماني</string>
<string name="calendars_restore_unavailable">لا تقويم يمكنه استيعاب الأحداث المستوردة بعد.</string>
<string name="calendars_description_label">الوصف</string>
<string name="app_name_calendar_alias">التقويم</string>
<plurals name="view_multi_day">
<item quantity="zero">%d يوم</item>
<item quantity="one">%d يوم</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<string name="settings_visible_hours_start">من</string>
<string name="settings_visible_hours_end">حتى</string>
<string name="settings_multi_day_count">عرض الأيام المتعدّدة</string>
<string name="settings_multi_day_count_hint">كم عدد الأيام التي يعرضها طريقة العرض متعدد الأيام جنبًا إلى جنب. اليوم يُفتح في العمود الأول.</string>
<string name="settings_visible_hours">الساعات الظاهرة</string>
<string name="settings_visible_hours_summary">حدّد مدى من الساعات للشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم</string>
<string name="settings_visible_hours_on_summary">%1$s – %2$s يملأ الشاشة عند فتح طريقة عرض</string>
<string name="settings_visible_hours_start_hint">الساعة في أعلى الشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم. لا تزال الساعات السابقة على بُعد تمرير.</string>
<string name="settings_visible_hours_end_hint">الساعة في أسفل الشاشة عند فتح طريقة عرض. لا تزال الساعات اللاحقة على بُعد تمرير، و عمليات التكبير بالضّم حتى يتم فتح طريقة العرض مرة أخرى.</string>
<string name="month_open_week">فتح الأسبوع %1$d</string>
<string name="settings_timeline_scale_by_visible_hours">ضبط بواسطة الساعات الظاهرة</string>
<string name="month_split_clear_filter">إظهار جدول الشهر كامل</string>
<string name="timeline_earlier_events">أحداث سابقة</string>
<string name="timeline_later_events">أحداث لاحقة</string>
<string name="reminder_day_today">اليوم</string>
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
</resources>
-1
View File
@@ -671,5 +671,4 @@
<string name="state_failure_all_hidden_action">Кіраваньне календарамі</string>
<string name="state_failure_no_import_target">Ніводны з календароў ня можа прымаць новыя падзеі.</string>
<string name="state_failure_no_import_target_body">Календары адключаныя, у рэжыме «толькі чытаньне», знаходзяцца пад кіраваньнем іншай праграмы, або не сынхранізуюцца на гэтай прыладзе. Уключэце каляндар, які можа прымаць падзеі, або дадайце лякальны.</string>
<string name="app_name_calendar_alias">Каляндар</string>
</resources>
+1 -18
View File
@@ -719,7 +719,7 @@
<string name="onboarding_hidden_title">Vaše kalendáře jsou vypnuté</string>
<string name="onboarding_hidden_body">Calendula zobrazuje kalendáře, které má tento telefon zapnuté, což není momentálně žádný. Zapněte si kalendáře, které chcete vidět – vaše ostatní aplikace kalendáře je pak také zobrazí.</string>
<string name="onboarding_hidden_switch_all_button">Zapnout všechny kalendáře</string>
<string name="onboarding_hidden_skip_button">Později</string>
<string name="onboarding_hidden_skip_button">Teď ne</string>
<string name="onboarding_hidden_failed">To se nepodařilo zapnout. V Nastavení → Kalendáře jsou stejné přepínače.</string>
<string name="onboarding_calendars_create_failed">Kalendář se nepodařilo vytvořit. Úložiště kalendáře ve vašem telefonu jej odmítlo vytvořit.</string>
<string name="calendars_description_label">Popis</string>
@@ -736,21 +736,4 @@
<string name="color_name_purple">Purpurová</string>
<string name="color_name_pink">Růžová</string>
<string name="calendars_restore_unavailable">Žádný z kalendářů zatím nedokáže importované události přijmout.</string>
<string name="app_name_calendar_alias">Kalendář</string>
<string name="month_open_week">Otevřít týden %1$d</string>
<string name="month_split_clear_filter">Zobrazit celou měsíční agendu</string>
<string name="timeline_earlier_events">Dřívější události</string>
<string name="timeline_later_events">Pozdější události</string>
<string name="event_detail_open_in_maps">Otevřít na mapě</string>
<string name="link_open_failed">Žádná aplikace nemůže tento odkaz otevřít</string>
<string name="reminder_day_today">Dnes</string>
<plurals name="view_multi_day">
<item quantity="one">%d den</item>
<item quantity="few">%d dny</item>
<item quantity="many">%d dnů</item>
<item quantity="other">%d dnů</item>
</plurals>
<string name="settings_multi_day_count">Vícedenní zobrazení</string>
<string name="settings_multi_day_count_hint">Počet dní zobrazujících se ve vícedenním přehledu vedle sebe. Dnešek se otevírá v prvním sloupci.</string>
<string name="settings_timeline_scale_by_visible_hours">Nastaveno v části Viditelné hodiny</string>
</resources>
-12
View File
@@ -715,16 +715,4 @@
<item quantity="one">%d Termin konnte nicht hinzugefügt werden.</item>
<item quantity="other">%d Termine konnten nicht hinzugefügt werden.</item>
</plurals>
<string name="app_name_calendar_alias">Kalender</string>
<string name="timeline_earlier_events">Frühere Termine</string>
<string name="timeline_later_events">Spätere Termine</string>
<string name="link_open_failed">Keine App kann diesen Link öffnen</string>
<string name="reminder_day_today">Heute</string>
<plurals name="view_multi_day">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
</plurals>
<string name="settings_visible_hours">Sichtbare Stunden</string>
<string name="settings_visible_hours_start">Von</string>
<string name="settings_visible_hours_end">Bis</string>
</resources>
-1
View File
@@ -672,5 +672,4 @@
<item quantity="many">Les événements %d n\'ont pas pu être ajoutés.</item>
<item quantity="other">Les événements %d n\'ont pas pu être ajoutés.</item>
</plurals>
<string name="app_name_calendar_alias">Calendrier</string>
</resources>
+3 -82
View File
@@ -243,7 +243,7 @@
<string name="settings_time_format_auto">Automatico</string>
<string name="settings_time_format_12h">12 ore</string>
<string name="settings_time_format_24h">24 ore</string>
<string name="settings_hour_lines">Grglia oraria</string>
<string name="settings_hour_lines">Righe per le ore</string>
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
<string name="settings_dim_completed">Attenua gli eventi completati</string>
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
@@ -413,7 +413,7 @@
<string name="settings_default_reminder">Promemoria per eventi standard</string>
<string name="settings_notifications_subtitle">Promemoria</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” — titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Lingua di sistema</string>
@@ -503,7 +503,7 @@
<string name="event_move_undone">Ripianificazione annullata</string>
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
<string name="event_move_gone">Questo evento è stato eliminato</string>
<string name="event_move_gone">Questo evento è stato eliminato.</string>
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
@@ -657,83 +657,4 @@
<string name="import_done_failed_label">Non aggiunto</string>
<string name="import_done_failed_note">Alcuni eventi non possono essere aggiunti e sono stati esclusi.</string>
<string name="import_done_stopped_title">Importazione interrotta</string>
<string name="app_name_calendar_alias">Calendario</string>
<string name="state_failure_permission_body">Calendula legge i tuoi eventi direttamente dal calendario di sistema, per questo ha bisogno dell\'accesso al calendario per poterli visualizzare.</string>
<string name="state_failure_no_calendars_body">Aggiungi un account a questo dispositivo e i relativi calendari verranno visualizzati qui.</string>
<string name="state_failure_all_hidden">Tutti i tuoi calendari sono disattivati.</string>
<string name="state_failure_all_hidden_body">Riattiva almeno un calendario per visualizzarne gli eventi.</string>
<string name="state_failure_no_import_target">Nessun calendario può accettare nuovi eventi.</string>
<string name="state_failure_no_import_target_body">I calendari attivi sono di sola lettura, gestiti da un\'altra app o non sincronizzati con questo dispositivo. Attiva un calendario in cui è possibile inserire eventi oppure aggiungine uno locale.</string>
<string name="state_failure_event_not_found">Questo evento non è più disponibile.</string>
<string name="state_failure_event_not_found_body">Potrebbe essere stato cancellato oppure spostato in un calendario disattivato.</string>
<string name="state_failure_provider_body">Il calendario di sistema non ha risposto. Riprova a breve.</string>
<string name="timeline_earlier_events">Eventi precedenti</string>
<string name="timeline_later_events">Eventi successivi</string>
<string name="month_open_week">Settimana aperta %1$d</string>
<string name="month_split_clear_filter">Visualizza il calendario completo del mese</string>
<string name="event_detail_open_in_maps">Apri su Maps</string>
<string name="link_open_failed">Nessuna app è in grado di aprire questo link</string>
<string name="calendar_unnamed">(Senza nome)</string>
<string name="onboarding_calendars_title">Un posto dove inserire i tuoi eventi</string>
<string name="onboarding_calendars_body">Questo dispositivo non ha ancora un calendario. Calendula scrive sui calendari già presenti sul telefono — ne serve almeno uno.</string>
<string name="onboarding_calendars_local_button">Crea un calendario su questo dispositivo</string>
<string name="onboarding_calendars_caldav_button">Sincronizzazione con un server CalDAV</string>
<string name="onboarding_calendars_skip_button">Non ora</string>
<string name="onboarding_calendars_benefit_local_title">Rimane su questo telefono</string>
<string name="onboarding_calendars_benefit_local_body">Il calendario del dispositivo non si sincronizza con nessun altro dispositivo, quindi esegui un backup se gli eventi sono importanti.</string>
<string name="onboarding_calendars_benefit_sync_title">Oppure sincronizza da un account</string>
<string name="onboarding_calendars_benefit_sync_body">Calendula mostra quali account sono già sincronizzati. I server CalDAV necessitano di DAVx⁵ per eseguire la sincronizzazione.</string>
<string name="onboarding_calendars_created_title">“%1$s” è pronto</string>
<string name="onboarding_calendars_created_body">Si trova su questo dispositivo e gli eventi nuovi vengono inseriti lì, a meno che non si scelga un altro calendario.</string>
<string name="onboarding_calendars_created_more_title">C\'è spazio per altro</string>
<string name="onboarding_calendars_created_more_body">Da Impostazioni → Calendari puoi crearne quanti ne vuoi e rinominare o cambiare il colore di quelli già presenti.</string>
<string name="onboarding_calendars_created_continue">Continua</string>
<string name="onboarding_hidden_title">I vostri calendari sono disattivati</string>
<string name="onboarding_hidden_body">Calendula mostra i calendari attivi su questo telefono e, al momento, non ce n’è nessuno. Attiva quelli che desideri visualizzare: anche le altre app di calendario li mostreranno nuovamente.</string>
<string name="onboarding_hidden_switch_all_button">Attivali tutti</string>
<string name="onboarding_hidden_skip_button">Non ora</string>
<string name="onboarding_hidden_failed">Non sono riuscito ad attivarlo. In Impostazioni → Calendari ci sono gli stessi pulsanti di attivazione.</string>
<string name="onboarding_calendars_create_failed">Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato.</string>
<string name="reminder_day_today">Oggi</string>
<plurals name="view_multi_day">
<item quantity="one">Giorno</item>
<item quantity="many">Giorni</item>
<item quantity="other">Giorno</item>
</plurals>
<string name="settings_multi_day_count">Visualizzazione su più giorni</string>
<string name="settings_multi_day_count_hint">La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna.</string>
<string name="settings_timeline_scale_by_visible_hours">Imposta in base alle ore di visibilità</string>
<string name="settings_visible_hours">Orari di apertura</string>
<string name="settings_visible_hours_summary">Adatta la gamma di ore alla schermata all\'apertura della visualizzazione settimanale, plurigiornaliera o giornaliera</string>
<string name="settings_visible_hours_on_summary">%1$s – %2$s riempie lo schermo all’apertura di una vista</string>
<string name="settings_visible_hours_start">Da</string>
<string name="settings_visible_hours_end">Fino a</string>
<string name="settings_visible_hours_start_hint">L\'ora visualizzata nella parte superiore dello schermo quando si apre la vista settimanale, plurigiornaliera o giornaliera. Le ore precedenti sono comunque accessibili scorrendo la schermata.</string>
<string name="settings_visible_hours_end_hint">L\'ora visualizzata nella parte inferiore dello schermo quando si apre una schermata. Le ore successive sono ancora raggiungibili scorrendo lo schermo, mentre pizzicando lo schermo si esegue lo zoom fino a quando la schermata non viene riaperta.</string>
<string name="settings_licences">Licenze open source</string>
<string name="settings_licences_subtitle">I progetti su cui si basa Calendula</string>
<string name="calendars_description_label">Descrizione</string>
<string name="color_name_mauve">Rosa malva</string>
<string name="color_name_red">Rosso</string>
<string name="color_name_orange">Arancione</string>
<string name="color_name_amber">Ambra</string>
<string name="color_name_olive">Oliva</string>
<string name="color_name_green">Verde</string>
<string name="color_name_teal">Verde acqua</string>
<string name="color_name_cyan">Ciano</string>
<string name="color_name_blue">Blu</string>
<string name="color_name_indigo">Indaco</string>
<string name="color_name_purple">Viola</string>
<string name="color_name_pink">Rosa</string>
<string name="calendars_restore_unavailable">Al momento nessun calendario è in grado di gestire gli eventi importati.</string>
<plurals name="import_done_stopped">
<item quantity="one">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
<item quantity="many">Il calendari hanno smesso di accettare eventi a metà dell\'operazione. Gli eventi %d presenti nel file non sono stati aggiunti.</item>
<item quantity="other">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="one">Non è stato possibile aggiungere l\'evento %d.</item>
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
</plurals>
</resources>
+1 -1
View File
@@ -2,5 +2,5 @@
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
(#101316) so activity recreation (e.g. language switch) doesn't flash a
lighter grey. See values/colors.xml. -->
<color name="window_background">#FF101316</color>
<color name="window_background">@color/window_background_dark</color>
</resources>
+2 -24
View File
@@ -377,7 +377,7 @@
<string name="settings_today_toolbar">Knop Vandaag in werkbalk</string>
<string name="settings_today_toolbar_summary">Een knop om naar vandaag te gaan tonen in de werkbalk, in plaats van een zwevende knop</string>
<string name="settings_app_name">Appnaam</string>
<string name="settings_app_name_summary">Calendula als “Agenda” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
<string name="settings_app_name_summary">Calendula als “Calendar” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
<string name="settings_time_format">Tijdnotatie</string>
<string name="settings_time_format_auto">Automatisch</string>
<string name="settings_time_format_12h">12-uursnotatie (2:00 PM)</string>
@@ -441,7 +441,7 @@
<string name="settings_month_header">Maandweergave</string>
<string name="settings_month_view_style">Stijl van maandweergave</string>
<string name="month_style_paged">Pagina\'s</string>
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen, of veeg omhoog om de agenda van deze maand te tonen.</string>
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen.</string>
<string name="month_style_continuous">Doorlopende maanden</string>
<string name="month_style_continuous_summary">Elke maand staat onder een eigen kop, met wat ruimte tot de volgende maand.</string>
<string name="month_style_dense">Naadloze weken</string>
@@ -694,26 +694,4 @@
<string name="special_dates_default_title_birthday">Verjaardag van {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Jubileum van {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="app_name_calendar_alias">Agenda</string>
<string name="month_open_week">Week %1$d openen</string>
<string name="month_split_clear_filter">Agenda van de hele maand tonen</string>
<string name="timeline_earlier_events">Eerdere gebeurtenissen</string>
<string name="timeline_later_events">Latere gebeurtenissen</string>
<string name="event_detail_open_in_maps">Op kaart tonen</string>
<string name="link_open_failed">Er is geen app die deze link kan openen</string>
<string name="reminder_day_today">Vandaag</string>
<plurals name="view_multi_day">
<item quantity="one">%d dag</item>
<item quantity="other">%d dagen</item>
</plurals>
<string name="settings_multi_day_count">Meerdaagse weergave</string>
<string name="settings_multi_day_count_hint">Hoeveel dagen de meerdaagse weergave naast elkaar toont. Vandaag staat in de eerste kolom.</string>
<string name="settings_timeline_scale_by_visible_hours">Ingesteld via Zichtbare uren</string>
<string name="settings_visible_hours">Zichtbare uren</string>
<string name="settings_visible_hours_summary">Een reeks uren passend op het scherm tonen bij het openen van de week-, meerdaagse of dagweergave</string>
<string name="settings_visible_hours_on_summary">%1$s – %2$s vult het scherm bij het openen van een weergave</string>
<string name="settings_visible_hours_start">Van</string>
<string name="settings_visible_hours_end">Tot</string>
<string name="settings_visible_hours_start_hint">Het uur bovenaan het scherm bij het openen van de week-, meerdaagse of dagweergave. Eerdere uren blijven bereikbaar door te scrollen.</string>
<string name="settings_visible_hours_end_hint">Het uur onderaan het scherm bij het openen van een weergave. Latere uren blijven bereikbaar door te scrollen, en in- of uitzoomen met twee vingers geldt totdat de weergave opnieuw wordt geopend.</string>
</resources>
+6 -1
View File
@@ -6,5 +6,10 @@
<!-- Window backdrop shown during activity recreation (e.g. on a language
switch). Matches the Compose light scheme background/surface so the
recreation is seamless; overridden for dark in values-night. -->
<color name="window_background">#FFFBFCFE</color>
<color name="window_background">@color/window_background_light</color>
<!-- The same backdrops pinned for an in-app theme override, which the night
qualifier can't see. MainActivity applies one before the first frame. -->
<color name="window_background_light">#FFFBFCFE</color>
<color name="window_background_dark">#FF101316</color>
<color name="window_background_black">#FF000000</color>
</resources>
+23
View File
@@ -97,6 +97,14 @@
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
<string name="event_move_done">Moved to %1$s</string>
<string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string>
<string name="event_selection_close">Deselect event</string>
<string name="event_draft_create">Create event</string>
<string name="event_draft_discard">Discard new event</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
<string name="event_move_failed">Couldn\'t move the event</string>
@@ -110,6 +118,11 @@
<!-- Event form (v1.2 create) -->
<string name="event_edit_new_title">New event</string>
<string name="event_edit_close">Close</string>
<string name="event_edit_more_actions">More actions</string>
<string name="event_edit_unsaved_title">Unsaved changes</string>
<string name="event_edit_unsaved_save">Save changes</string>
<string name="event_edit_unsaved_discard">Discard changes</string>
<string name="event_edit_unsaved_keep">Keep editing</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
@@ -441,6 +454,8 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
<string name="settings_theme_system_summary">Currently %1$s</string>
@@ -510,6 +525,11 @@
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
<string name="settings_events_header">Events</string>
<string name="settings_tap_opens">Tapping an event opens</string>
<string name="settings_tap_opens_details">Details</string>
<string name="settings_tap_opens_editor">Edit form</string>
<string name="settings_tap_opens_hint">Events you can\'t edit and reminder notifications always open the details.</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
@@ -826,6 +846,9 @@
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>
<!-- Text-selection menu entry -->
<string name="process_text_create_event">Create event</string>
<!-- Quick Settings tile -->
<string name="qs_tile_new_event_label">New event</string>
<string name="settings_qs_tile">Add Quick Settings tile</string>
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -510,8 +511,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val id = repo.createEvent(form)
@@ -531,8 +532,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0))),
)
repo.createEvent(form)
@@ -548,8 +549,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -567,8 +568,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(title = "Daily")
@@ -584,8 +585,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
val updated = original.copy(calendarId = 3L)
@@ -605,8 +606,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -625,8 +626,8 @@ class CalendarRepositoryImplTest {
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
try {
@@ -678,8 +679,8 @@ class CalendarRepositoryImplTest {
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
)
val id = repo.updateOccurrence(
@@ -701,8 +702,8 @@ class CalendarRepositoryImplTest {
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0))),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
@@ -5,12 +5,15 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.UtcOffset
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
class EventWriteMapperTest {
@@ -24,8 +27,8 @@ class EventWriteMapperTest {
): EventForm = EventForm(
calendarId = 1L,
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -154,8 +157,8 @@ class EventWriteMapperTest {
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
.isEqualTo("America/New_York")
@@ -201,8 +204,8 @@ class EventWriteMapperTest {
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
@@ -221,8 +224,8 @@ class EventWriteMapperTest {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30))),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
@@ -263,8 +266,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -279,8 +282,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
@@ -297,8 +300,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0))),
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
)
@@ -319,8 +322,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30))),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
@@ -331,8 +334,8 @@ class EventWriteMapperTest {
fun `shifting a one-off event and back again restores the original values`() {
val original = form()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0))),
)
val forward = update(original, moved)
@@ -357,8 +360,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
@@ -678,8 +681,8 @@ class EventWriteMapperTest {
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0))),
)
@Test
@@ -728,8 +731,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -748,8 +751,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000Z")
@@ -768,8 +771,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T000000")
@@ -787,8 +790,8 @@ class EventWriteMapperTest {
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0))),
)
val values = update(original, moved, series, "20260722T090000Z")
@@ -815,8 +818,8 @@ class EventWriteMapperTest {
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0))),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
@@ -898,8 +901,8 @@ class EventWriteMapperTest {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30))),
)
val exdate = "20260722T070000Z"
@@ -1387,4 +1390,67 @@ class EventWriteMapperTest {
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a time in the repeated fall-back hour is written at its own offset`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 0))
val times = form(start = repeated, end = repeated)
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
.toWriteTimes(berlin)
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
}
@Test
fun `a move between the two repeated fall-back hours writes the new times`() {
// 02:30 CEST to 02:30 CET: the same wall clock, an hour later.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 2)))
val updated = original.copy(
start = original.start.copy(offset = UtcOffset(hours = 1)),
end = original.end.copy(offset = UtcOffset(hours = 1)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
}
@Test
fun `an untouched form in the repeated fall-back hour writes no times`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), end = FormTime(repeated, UtcOffset(hours = 1)))
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.DTSTART)
}
@Test
fun `a series anchored on the second repeated hour keeps it through a time edit`() {
// The series starts 02:30 CET on the fall-back day; an occurrence a week
// later gets ten more minutes.
val anchor = Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds()
val original = form(
start = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 30)),
).copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(end = FormTime(LocalDateTime(LocalDate(2026, 11, 1), LocalTime(3, 40))))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(anchor)
}
@Test
fun `a series anchor moved between the repeated hours follows the form`() {
// Editing the anchor occurrence itself from 02:30 CEST onto 02:30 CET.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val anchor = Instant.parse("2026-10-25T00:30:00Z").toEpochMilliseconds()
val original = form(start = repeated, end = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(4, 0)))
.copy(start = FormTime(repeated, UtcOffset(hours = 2)), rrule = "FREQ=WEEKLY")
val updated = original.copy(start = FormTime(repeated, UtcOffset(hours = 1)))
val values = update(original, updated, series = anchor)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
}
}
@@ -47,6 +47,13 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `black theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setThemeMode(ThemeMode.BLACK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.BLACK)
}
@Test
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.calendula.data.time
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.TimeZone
/** A device zone a test sets, by default the machine's own. */
class FakeZoneSource(zone: TimeZone = TimeZone.currentSystemDefault()) : ZoneSource {
override val zone: MutableStateFlow<TimeZone> = MutableStateFlow(zone)
override fun current(): TimeZone = zone.value
}
@@ -5,6 +5,8 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Instant
@@ -15,29 +17,29 @@ class EventFormTest {
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = FormTime(start), end = FormTime(end))
@Test
fun `valid timed form has no problems`() {
assertThat(form().problems()).isEmpty()
assertThat(form().problems(berlin)).isEmpty()
}
@Test
fun `missing calendar is a problem`() {
assertThat(form(calendarId = null).problems())
assertThat(form(calendarId = null).problems(berlin))
.containsExactly(EventFormProblem.NoCalendar)
}
@Test
fun `timed end before start is a problem`() {
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `zero-length timed event is allowed`() {
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(instant.problems()).isEmpty()
assertThat(instant.problems(berlin)).isEmpty()
}
@Test
@@ -47,7 +49,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
)
assertThat(allDay.problems()).isEmpty()
assertThat(allDay.problems(berlin)).isEmpty()
}
@Test
@@ -57,7 +59,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
)
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
@@ -66,7 +68,7 @@ class EventFormTest {
calendarId = null,
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
)
assertThat(bad.problems()).containsExactly(
assertThat(bad.problems(berlin)).containsExactly(
EventFormProblem.NoCalendar,
EventFormProblem.EndBeforeStart,
)
@@ -76,28 +78,28 @@ class EventFormTest {
fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems())
assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@Test
fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems(berlin)).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty()
}
@Test
fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged.
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems(berlin)).isEmpty()
}
@Test
fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems())
assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@@ -150,8 +152,8 @@ class EventFormTest {
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
@@ -182,7 +184,7 @@ class EventFormTest {
assertThat(prefilled.timezone).isEqualTo("America/New_York")
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
// event's own wall-clock, which is what a later save re-anchors to.
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
}
@@ -206,68 +208,27 @@ class EventFormTest {
)
// A malformed sync row must not be honoured, nor fail the open.
assertThat(prefilled.timezone).isNull()
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
}
@Test
fun `timesIn converts a pinned event into the target zone`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
timezone = "America/New_York",
)
val (start, end) = form.timesIn(berlin)!!
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
}
@Test
fun `timesIn crosses the date line when the offset pushes past midnight`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
timezone = "America/New_York",
)
val (start, _) = form.timesIn(berlin)!!
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
// with it, not just the clock.
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
}
@Test
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
// In January both are on standard time: 08:00 EST is 14:00 CET — the
// same six hours as July, but only because both shifted. Late March,
// when the US has sprung forward and Europe hasn't, the gap is five.
val march = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
timezone = "America/New_York",
)
assertThat(march.timesIn(berlin)!!.first)
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
}
@Test
fun `timesIn returns null when there is nothing to disambiguate`() {
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
val base = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
)
// Unpinned: the form's times already are the local times.
assertThat(base.timesIn(berlin)).isNull()
assertThat(base.isPinnedAwayFrom(berlin)).isFalse()
// Pinned to the target itself: same thing.
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
assertThat(base.copy(timezone = "Europe/Berlin").isPinnedAwayFrom(berlin)).isFalse()
// All-day: date-anchored, so there's no zone conversion to show.
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
.isNull()
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin))
.isFalse()
// Unparseable: can't convert, mustn't throw.
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
assertThat(base.copy(timezone = "Mars/Olympus_Mons").isPinnedAwayFrom(berlin)).isFalse()
// Pinned elsewhere: worth spelling out.
assertThat(base.copy(timezone = "America/New_York").isPinnedAwayFrom(berlin)).isTrue()
}
@Test
@@ -425,4 +386,190 @@ class EventFormTest {
assertThat(prefilled.color).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
}
@Test
fun `toEditForm keeps which repeated fall-back hour each end is in`() {
val start = Instant.parse("2026-10-25T00:00:00Z")
val end = Instant.parse("2026-10-25T01:00:00Z")
val prefilled = detail().toEditForm(start.toEpochMilliseconds(), end.toEpochMilliseconds(), berlin)
assertThat(prefilled.start.dateTime).isEqualTo(prefilled.end.dateTime)
assertThat(prefilled.startInstant(berlin)).isEqualTo(start)
assertThat(prefilled.endInstant(berlin)).isEqualTo(end)
}
@Test
fun `a span ending in the repeated fall-back hour is valid`() {
// 02:45 CEST to 02:15 CET: half an hour, though the clock reads backwards.
val form = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
assertThat(form.end.dateTime).isLessThan(form.start.dateTime)
assertThat(form.problems(berlin)).isEmpty()
// Read as the same pass at both ends, it really does end first.
assertThat(form.copy(start = FormTime(form.start.dateTime), end = FormTime(form.end.dateTime)).problems(berlin))
.containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `an end picked in the repeated hour moves the way the hand did`() {
// 02:45 CEST to 02:15 CET.
val form = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
// Picking the end it already has keeps its pass.
assertThat(form.withEnd(form.end.dateTime, berlin)).isEqualTo(form)
// Later on the clock: later in time, still on the second pass.
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)), berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:50:00Z"))
// Earlier on the clock: earlier in time, not an hour earlier.
assertThat(form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 5)), berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:05:00Z"))
}
@Test
fun `an end picked in the repeated hour stays after a second-pass start`() {
// 02:10 CET to 04:00, its end set to 02:40: only the second pass is after the start.
val form = form().withTimes(
Instant.parse("2026-10-25T01:10:00Z"),
Instant.parse("2026-10-25T03:00:00Z"),
berlin,
)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 40)), berlin)
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:40:00Z"))
assertThat(picked.problems(berlin)).isEmpty()
}
@Test
fun `a repeated-hour end changed and changed back is the end it was`() {
// 01:00 CEST to 02:30 CEST, its end set to 03:00 and back.
val original = form().withTimes(
Instant.parse("2026-10-24T23:00:00Z"),
Instant.parse("2026-10-25T00:30:00Z"),
berlin,
)
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val away = original.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(3, 0)), berlin)
assertThat(away.withEnd(repeated, berlin, original = original.end)).isEqualTo(original)
// Without the opened end to go by, coming back from 03:00 reads as the later pass.
assertThat(away.withEnd(repeated, berlin).endInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
}
@Test
fun `a start picked in the repeated hour keeps the length`() {
// 02:30 CET to 03:30, its start set back to 02:10.
val form = form().withTimes(
Instant.parse("2026-10-25T01:30:00Z"),
Instant.parse("2026-10-25T02:30:00Z"),
berlin,
)
val picked = form.withStart(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 10)), berlin)
assertThat(picked.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:10:00Z"))
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:10:00Z"))
}
@Test
fun `a pinned event's times land in the device zone`() {
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0))),
timezone = "America/New_York",
)
val zone = form.resolvedZone(berlin)
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
assertThat(form.startInstant(zone).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
assertThat(form.endInstant(zone).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
}
@Test
fun `a pinned event's local time crosses the date line with the offset`() {
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0))),
timezone = "America/New_York",
)
// 20:00 New York is 02:00 the NEXT day in Berlin.
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
}
@Test
fun `a pinned event's local time tracks each zone's own DST`() {
// Late March the US has sprung forward and Europe hasn't: five hours, not six.
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0))),
timezone = "America/New_York",
)
assertThat(form.startInstant(form.resolvedZone(berlin)).toLocalDateTime(berlin))
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
}
@Test
fun `offsets are kept only for a repeated hour`() {
val summer = form().withTimes(
Instant.parse("2026-06-11T08:00:00Z"),
Instant.parse("2026-06-11T09:00:00Z"),
berlin,
)
assertThat(summer.start.offset).isNull()
assertThat(summer.end.offset).isNull()
val repeated = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
assertThat(repeated.start.offset).isEqualTo(UtcOffset(hours = 2))
assertThat(repeated.end.offset).isEqualTo(UtcOffset(hours = 1))
}
@Test
fun `an end changed and changed back is the form it was`() {
val original = detail().toEditForm(
Instant.parse("2026-06-11T08:00:00Z").toEpochMilliseconds(),
Instant.parse("2026-06-11T09:00:00Z").toEpochMilliseconds(),
berlin,
)
val back = original
.withEnd(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)), berlin)
.withEnd(original.end.dateTime, berlin)
assertThat(back).isEqualTo(original)
}
@Test
fun `a new zone drops the kept pass, the same zone keeps it`() {
val repeated = form().withTimes(
Instant.parse("2026-10-25T01:30:00Z"),
Instant.parse("2026-10-25T02:00:00Z"),
berlin,
)
assertThat(repeated.withTimezone("Europe/Berlin", berlin)).isEqualTo(repeated.copy(timezone = "Europe/Berlin"))
val moved = repeated.withTimezone("Africa/Lagos", berlin)
assertThat(moved.start.offset).isNull()
assertThat(moved.end.offset).isNull()
// Back on Berlin, a picked 02:30 takes the earlier pass, as any picked time does.
assertThat(moved.withTimezone(null, berlin).startInstant(berlin))
.isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
}
@Test
fun `an end picked in the spring-forward gap moves on as a start does`() {
val form = form(
start = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 30)),
)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(picked.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
}
@@ -308,6 +308,23 @@ class EventSearchTest {
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
}
@Test
fun `an html description matches and snippets on its text, not its tags`() {
val html = "<div class=\"x\"><p>Raum&nbsp;<b>4.12</b></p></div>"
assertThat(search(candidate("Notiz", description = html), query = "div").isEmpty).isTrue()
val snippet = search(candidate("Notiz", description = html), query = "raum 4.12")
.allHits.single().descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("Raum 4.12")
}
@Test
fun `a link address in an html description is still searchable`() {
val html = "<p><a href=\"https://zoom.us/j/81234567890\">Join Zoom Meeting</a></p>"
assertThat(search(candidate("Call", description = html), query = "81234567890").allHits).hasSize(1)
}
@Test
fun `a blank query matches nothing`() {
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
@@ -5,8 +5,10 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventShiftTest {
@@ -22,8 +24,8 @@ class EventShiftTest {
calendarId = 1L,
title = "Standup",
isAllDay = isAllDay,
start = start,
end = end,
start = FormTime(start),
end = FormTime(end),
timezone = timezone,
)
@@ -34,8 +36,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
}
@Test
@@ -52,8 +54,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.timezone).isEqualTo("America/New_York")
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
}
@Test
@@ -69,10 +71,10 @@ class EventShiftTest {
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
// between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(original.end.dateTime.toInstant(berlin) - original.start.dateTime.toInstant(berlin))
}
@Test
@@ -83,13 +85,15 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
fun `a zero-distance drop leaves the form untouched`() {
val original = form()
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
// As read from the provider, offsets and all.
val start = form().start.dateTime.toInstant(berlin)
val original = form().withTimes(start, form().end.dateTime.toInstant(berlin), berlin)
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
}
@Test
@@ -99,7 +103,7 @@ class EventShiftTest {
val moved = original.shiftedTo(target, newYork)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
}
@Test
@@ -125,8 +129,8 @@ class EventShiftTest {
val moved = multiDay.shiftedByDays(-2, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
@@ -140,11 +144,11 @@ class EventShiftTest {
val moved = overnight.shiftedByDays(7, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.dateTime.toInstant(berlin) - moved.start.dateTime.toInstant(berlin))
.isEqualTo(overnight.end.dateTime.toInstant(berlin) - overnight.start.dateTime.toInstant(berlin))
// The wall-clock end therefore lands an hour later than a naive +7 days.
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
assertThat(moved.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
}
@Test
@@ -152,4 +156,111 @@ class EventShiftTest {
val original = form()
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
}
@Test
fun `resizing sets each edge on its own`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.start).isEqualTo(original.start)
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
}
@Test
fun `a resize past midnight carries the end onto the next day`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
val resized = original.resizedTo(original.start.dateTime.toInstant(berlin), end, berlin)
assertThat(resized.end.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
}
@Test
fun `a pinned event is resized in its own zone`() {
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
timezone = "America/New_York",
)
// "15:30 Berlin" is 09:30 in New York.
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val resized = original.resizedTo(start, original.end.dateTime.toInstant(newYork), berlin)
assertThat(resized.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.end).isEqualTo(original.end)
}
@Test
fun `an all-day event ignores a resize`() {
val allDay = form(isAllDay = true)
val start = allDay.start.dateTime.toInstant(berlin)
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
// 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value.
private val repeatStart = Instant.parse("2026-10-25T00:00:00Z")
private val repeatEnd = Instant.parse("2026-10-25T01:00:00Z")
@Test
fun `moving an event in the repeated fall-back hour keeps its length`() {
val original = form().withTimes(repeatStart, repeatEnd, berlin)
val moved = original.shiftedTo(Instant.parse("2026-10-25T00:30:00Z"), berlin)
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
}
@Test
fun `a move onto the second repeated hour stays there`() {
val target = Instant.parse("2026-10-25T01:30:00Z")
val moved = form().withTimes(repeatStart, repeatEnd, berlin).shiftedTo(target, berlin)
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
assertThat(moved.startInstant(berlin)).isEqualTo(target)
}
@Test
fun `resolveIn takes the preferred offset only where the hour repeats`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val cet = UtcOffset(hours = 1)
assertThat(repeated.resolveIn(berlin, cet)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(repeated.resolveIn(berlin, null)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
// Not one of 14:00's offsets on a summer day: ignored.
val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0))
assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin))
}
@Test
fun `a day move onto the repeated hour doesn't depend on the source's season`() {
// 02:30 a week after the fall-back day (CET) and in summer (CEST), both
// moved onto it: the earlier pass either way, as a timeline drop.
val first = Instant.parse("2026-10-25T00:30:00Z")
val november = form().withTimes(
Instant.parse("2026-11-01T01:30:00Z"),
Instant.parse("2026-11-01T02:30:00Z"),
berlin,
)
assertThat(november.shiftedByDays(-7, berlin).startInstant(berlin)).isEqualTo(first)
val summer = form(
start = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 10, 18), LocalTime(3, 30)),
)
assertThat(summer.shiftedByDays(7, berlin).startInstant(berlin)).isEqualTo(first)
}
@Test
fun `a day move onto the spring-forward gap keeps its wall clock`() {
// Saturday 02:30 to 03:30, moved onto Sunday 29 March, when 02:30 is skipped.
val moved = form(
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(2, 30)),
end = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(3, 30)),
).shiftedByDays(1, berlin)
assertThat(moved.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)))
// It still means 03:30 CEST, an hour long.
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T01:30:00Z"))
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-03-29T02:30:00Z"))
}
}
@@ -0,0 +1,213 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HtmlTextTest {
@Test
fun `prose with angle brackets is not html`() {
assertThat(looksLikeHtml("I <3 this, and a < b > c")).isFalse()
assertThat(looksLikeHtml("Teams Meeting <https://teams.microsoft.com/l/x>")).isFalse()
assertThat(looksLikeHtml("Bring <cake>")).isFalse()
}
@Test
fun `common tags are html`() {
assertThat(looksLikeHtml("Line one<br>Line two")).isTrue()
assertThat(looksLikeHtml("<p class=MsoNormal>Hi</p>")).isTrue()
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
}
@Test
fun `plain text passes through untouched`() {
val text = " Agenda:\n\n\n- one \n- two"
assertThat(descriptionRichText(text)).isEqualTo(RichText(text))
}
@Test
fun `blocks and breaks become lines and whitespace collapses`() {
val html = "<div>Hello\n world</div><div>Second<br>Third</div>"
assertThat(parseHtml(html).text).isEqualTo("Hello world\nSecond\nThird")
}
@Test
fun `head, styles, scripts and comments are dropped`() {
val html = """
<html><head><meta charset="utf-8"><style>p { color: red; }</style></head>
<body><!-- converted from text --><script>alert(1)</script><p>Body</p></body></html>
""".trimIndent()
assertThat(parseHtml(html).text).isEqualTo("Body")
}
@Test
fun `an nbsp paragraph keeps one blank line, more collapse`() {
val html = "<p>One</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>Two</p>"
assertThat(parseHtml(html).text).isEqualTo("One\n\nTwo")
}
@Test
fun `entities are decoded`() {
assertThat(parseHtml("<p>Fish &amp; chips &lt;3 &#8364;5 &#x2013; caf&eacute; &bogus;</p>").text)
.isEqualTo("Fish & chips <3 €5 – café &bogus;")
}
@Test
fun `inline styles cover their text`() {
val rich = parseHtml("<p>A <b>bold</b> and <i>italic</i> word</p>")
assertThat(rich.text).isEqualTo("A bold and italic word")
assertThat(rich.styles).containsExactly(
StyleRange(2, 6, InlineStyle.Bold),
StyleRange(11, 17, InlineStyle.Italic),
)
}
@Test
fun `fonts and colours are not kept`() {
val rich = parseHtml("<span style=\"color:#000\"><font face=Calibri size=2>Text</font></span>")
assertThat(rich).isEqualTo(RichText("Text"))
}
@Test
fun `links keep their href, unsafe schemes are dropped`() {
val rich = parseHtml(
"<a href=\"https://teams.microsoft.com/l/x?a=1&amp;b=2\">Join</a> " +
"<a href='tel:+4930123,,456#'>Dial</a> <a href=\"javascript:alert(1)\">Bad</a>",
)
assertThat(rich.text).isEqualTo("Join Dial Bad")
assertThat(rich.links).containsExactly(
LinkRange(0, 4, "https://teams.microsoft.com/l/x?a=1&b=2"),
LinkRange(5, 9, "tel:+4930123,,456#"),
)
}
@Test
fun `lists get bullets and numbers`() {
val html = "<p>Agenda</p><ul><li>One</li><li>Two<ol><li>Sub</li></ol></li></ul><ol><li>A</li><li>B</li></ol>"
assertThat(parseHtml(html).text).isEqualTo("Agenda\n• One\n• Two\n 1. Sub\n1. A\n2. B")
}
@Test
fun `table rows become lines with cells side by side`() {
val html = "<table><tr><th>Time</th><th>Topic</th></tr><tr><td>9:00</td><td>Intro</td></tr>" +
"<tr><td>&nbsp;</td><td>Spacer row</td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Time · Topic\n9:00 · Intro\nSpacer row")
}
@Test
fun `formatting on a dropped spacer cell doesn't leak onto the next`() {
val rich = parseHtml("<table><tr><td><b>&nbsp;</b></td><td>Plain</td></tr></table>")
assertThat(rich).isEqualTo(RichText("Plain"))
}
@Test
fun `pre keeps its whitespace`() {
assertThat(parseHtml("<pre>a b\n c</pre>").text).isEqualTo("a b\n c")
}
@Test
fun `spans shift with trimmed whitespace`() {
val rich = parseHtml("<p>&nbsp;</p><p> <b>Bold</b> </p>")
assertThat(rich.text).isEqualTo("Bold")
assertThat(rich.styles).containsExactly(StyleRange(0, 4, InlineStyle.Bold))
}
@Test
fun `broken markup stays text`() {
assertThat(parseHtml("<p>x < y and <b>z</p>").text).isEqualTo("x < y and z")
assertThat(parseHtml("<p>open <a href=\"https://a.b\">tail").links)
.containsExactly(LinkRange(5, 9, "https://a.b"))
}
@Test
fun `a paragraph inside a list item stays on the bullet's line`() {
assertThat(parseHtml("<ul><li><p>One</p></li><li><div>Two</div></li></ul>").text)
.isEqualTo("• One\n• Two")
}
@Test
fun `newlines are the lines when the markup sets none`() {
val rich = parseHtml("Agenda:\n1. Intro\n2. <a href=\"https://x.y\">Q&amp;A</a>")
assertThat(rich.text).isEqualTo("Agenda:\n1. Intro\n2. Q&A")
}
@Test
fun `newlines in markup with its own lines are just spaces`() {
assertThat(parseHtml("<p>wrapped\nsource line</p>").text).isEqualTo("wrapped source line")
}
@Test
fun `a link scheme is lower-cased`() {
assertThat(parseHtml("<a href=\"HTTPS://Teams.example/Join\">Join</a>").links.single().url)
.isEqualTo("https://Teams.example/Join")
}
@Test
fun `a query parameter is not read as an entity`() {
assertThat(parseHtml("<a href=\"https://x.example/?id=5&reg=eu&times=3\">x</a>").links.single().url)
.isEqualTo("https://x.example/?id=5&reg=eu&times=3")
}
@Test
fun `outlook cells with paragraphs still sit side by side`() {
val html = "<table><tr><td><p class=MsoNormal>9:00</p></td><td><p class=MsoNormal><b>Intro</b></p></td></tr>" +
"<tr><td><p>10:00</p></td><td><p>Talk</p></td></tr></table>"
val rich = parseHtml(html)
assertThat(rich.text).isEqualTo("9:00 · Intro\n10:00 · Talk")
assertThat(rich.styles).containsExactly(StyleRange(7, 12, InlineStyle.Bold))
}
@Test
fun `empty cells anywhere in a row leave no separator`() {
val html = "<table><tr><td>Time</td><td>&nbsp;</td><td>Topic</td><td></td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Time · Topic")
}
@Test
fun `a trailing empty cell doesn't join the text after the table`() {
val html = "<table><tr><td>10:00</td><td>Demo</td><td></td></tr></table><p>After</p>"
assertThat(parseHtml(html).text).isEqualTo("10:00 · Demo\nAfter")
}
@Test
fun `an unclosed head or style doesn't swallow the description`() {
assertThat(parseHtml("<html><head><meta charset=utf-8><body><p>Agenda</p>").text).isEqualTo("Agenda")
assertThat(parseHtml("<style>p{}<p>Agenda</p>").text).contains("Agenda")
}
@Test
fun `a list in a later cell keeps its bullet clean`() {
val html = "<table><tr><td>Agenda</td><td><ul><li>Intro</li></ul></td></tr></table>"
assertThat(parseHtml(html).text).isEqualTo("Agenda\n• Intro")
}
@Test
fun `an empty trailing item doesn't pull the next paragraph into the list`() {
assertThat(parseHtml("<ul><li>A</li><li></li></ul><p>Next steps</p>").text).isEqualTo("• A\nNext steps")
}
@Test
fun `an ordered list honours its start`() {
assertThat(parseHtml("<ol start=\"3\"><li>C</li><li>D</li></ol>").text).isEqualTo("3. C\n4. D")
}
@Test
fun `every tag the renderer knows counts as html`() {
assertThat(looksLikeHtml("Run <code>make deploy</code>")).isTrue()
assertThat(looksLikeHtml("Price <del>50</del> 40")).isTrue()
}
@Test
fun `latin-1 entities decode`() {
assertThat(decodeEntities("Le&oacute;n, &iacute;ndice, &Aring;s, &yuml;")).isEqualTo("León, índice, Ås, ÿ")
}
@Test
fun `copied text keeps the addresses of its links`() {
val rich = parseHtml(
"<p><a href=\"https://teams.example/join\">Join</a> or <a href=\"https://x.y\">https://x.y</a>" +
" <a href=\"mailto:a@b.c\">Mail</a></p>",
)
assertThat(rich.copyText()).isEqualTo("Join (https://teams.example/join) or https://x.y Mail (a@b.c)")
}
}
@@ -53,8 +53,8 @@ class InsertEventFormTest {
)
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.isAllDay).isFalse()
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(end)
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(end)
assertThat(form.title).isEqualTo("Standup")
assertThat(form.description).isEqualTo("Daily")
assertThat(form.location).isEqualTo("Room 1")
@@ -64,8 +64,8 @@ class InsertEventFormTest {
@Test
fun `missing times default to the next full hour and a one-hour duration`() {
val form = build(title = "Quick note")
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.title).isEqualTo("Quick note")
}
@@ -73,8 +73,8 @@ class InsertEventFormTest {
fun `timed event with only a start gets a one-hour end`() {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val form = build(beginMillis = millis(start))
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.start.dateTime).isEqualTo(start)
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -82,7 +82,7 @@ class InsertEventFormTest {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class SharedTextTest {
@Test
fun `subject becomes the title and the text the description`() {
assertThat(sharedTextOrNull("Dinner", "Friday at Luigi's\nBring wine"))
.isEqualTo(SharedText("Dinner", "Friday at Luigi's\nBring wine"))
}
@Test
fun `without a subject the first line is the title and the rest the description`() {
assertThat(sharedTextOrNull(null, "\n Dentist \n\nCheck-up, room 4\n"))
.isEqualTo(SharedText("Dentist", "Check-up, room 4"))
}
@Test
fun `a single line is the title only`() {
assertThat(sharedTextOrNull(" ", " Call mum ")).isEqualTo(SharedText("Call mum", ""))
}
@Test
fun `subject without text leaves the description empty`() {
assertThat(sharedTextOrNull("Meeting", null)).isEqualTo(SharedText("Meeting", ""))
}
@Test
fun `a long first line is cut and the full text kept as the description`() {
val line = "a".repeat(300)
val shared = sharedTextOrNull(null, "$line\nmore")!!
assertThat(shared.title).isEqualTo("a".repeat(255))
assertThat(shared.description).isEqualTo("$line\nmore")
}
@Test
fun `a long subject is cut and kept in full above the text`() {
val subject = "s".repeat(300)
val shared = sharedTextOrNull(subject, "body")!!
assertThat(shared.title).hasLength(255)
assertThat(shared.description).isEqualTo("$subject\n\nbody")
}
@Test
fun `the cut never splits a surrogate pair`() {
val shared = sharedTextOrNull(null, "a".repeat(254) + "😀" + "b")!!
assertThat(shared.title).isEqualTo("a".repeat(254))
}
@Test
fun `nothing usable is null`() {
assertThat(sharedTextOrNull(null, null)).isNull()
assertThat(sharedTextOrNull(" ", "\n \n")).isNull()
}
}
@@ -29,8 +29,8 @@ class ParsedIcsFormTest {
assertThat(form.calendarId).isNull()
assertThat(form.title).isEqualTo("Call")
// 13:00 UTC == 15:00 Berlin (summer).
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.start.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end.dateTime).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.reminders).containsExactly(5, 10).inOrder()
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
}
@@ -9,6 +9,7 @@ class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
private val yesterday = LocalDate(2026, 6, 16)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
@@ -36,4 +37,18 @@ class AnchorTodayTest {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
@Test
fun `enabled slots today in after loaded past days`() {
val days = listOf(AgendaDay(yesterday, emptyList()), AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result.map { it.date }).containsExactly(yesterday, today, tomorrow).inOrder()
}
@Test
fun `enabled appends today after only past days`() {
val days = listOf(AgendaDay(yesterday, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result.map { it.date }).containsExactly(yesterday, today).inOrder()
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
/** A block drafted on an empty timeline slot (#153). */
class DraftBlockTest {
private val zone = TimeZone.UTC
private val day = LocalDate(2026, 10, 9)
private fun draft(startMin: Int, minutes: Int) =
draftEvent("New event", 0, wallClockInstant(day, startMin, zone), minutes)
@Test
fun `a press snaps down to the slot it is in`() {
assertThat(draftStartMin(9 * 60 + 14f)).isEqualTo(9 * 60)
assertThat(draftStartMin(9 * 60 + 15f)).isEqualTo(9 * 60 + 15)
}
@Test
fun `a press at the very bottom still leaves a slot before midnight`() {
assertThat(draftStartMin(24 * 60f)).isEqualTo(24 * 60 - DRAG_SNAP_MINUTES)
}
@Test
fun `the draft is told apart from real events`() {
assertThat(draft(9 * 60, 60).isDraft).isTrue()
}
@Test
fun `a draft within its day is one block the column wide`() {
val block = draftBlock(draft(9 * 60, 60), day, zone)!!
assertThat(block.startMin).isEqualTo(9 * 60)
assertThat(block.endMin).isEqualTo(10 * 60)
assertThat(block.lane to block.laneCount).isEqualTo(0 to 1)
}
@Test
fun `a draft past midnight is cut there and carries on the next day`() {
val event = draft(23 * 60, 120)
val first = draftBlock(event, day, zone)!!
val second = draftBlock(event, day.plusDays(1), zone)!!
assertThat(first.startMin to first.endMin).isEqualTo(23 * 60 to 24 * 60)
assertThat(second.startMin to second.endMin).isEqualTo(0 to 60)
}
@Test
fun `a draft ending at midnight leaves the next day alone`() {
assertThat(draftBlock(draft(23 * 60, 60), day.plusDays(1), zone)).isNull()
}
@Test
fun `a draft misses the day before it`() {
assertThat(draftBlock(draft(9 * 60, 60), day.plusDays(-1), zone)).isNull()
}
@Test
fun `a draft past midnight opens the form with its end on the next day`() {
val span = wallClockSpan(draft(23 * 60, 120), zone)
assertThat(span).isEqualTo(WallClockSpan(day, 23 * 60, 25 * 60))
assertThat(wallClockTime(span.day, span.endMin)).isEqualTo(wallClockTime(day.plusDays(1), 60))
}
private fun LocalDate.plusDays(days: Int): LocalDate = LocalDate.fromEpochDays(toEpochDays() + days)
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class IsPastOnTest {
private val zone = TimeZone.of("Europe/Berlin")
private val today = LocalDate(2026, 10, 8)
private val now = Instant.parse("2026-10-08T12:00:00Z")
// All-day Oct 6–9, still running today.
private val summit = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
title = "Summit",
start = Instant.parse("2026-10-06T00:00:00Z"),
end = Instant.parse("2026-10-10T00:00:00Z"),
isAllDay = true,
color = 0,
location = null,
)
@Test
fun `an ongoing multi-day event is past on the days already behind`() {
assertThat(summit.isPastOn(LocalDate(2026, 10, 7), now, zone)).isTrue()
}
@Test
fun `an ongoing multi-day event is not past today or later`() {
assertThat(summit.isPastOn(today, now, zone)).isFalse()
assertThat(summit.isPastOn(LocalDate(2026, 10, 9), now, zone)).isFalse()
}
@Test
fun `an event that ended earlier today is past`() {
val standup = summit.copy(
isAllDay = false,
start = Instant.parse("2026-10-08T07:00:00Z"),
end = Instant.parse("2026-10-08T07:15:00Z"),
)
assertThat(standup.isPastOn(today, now, zone)).isTrue()
}
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import java.util.Locale
import kotlin.time.Instant
/** The zone named after a time only where the fall-back hour repeats it (#387). */
class RepeatedHourZoneTest {
private val brussels = TimeZone.of("Europe/Brussels")
@Test
fun `both 02 30s of the fall-back day are told apart`() {
val first = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T00:30:00Z"), brussels, Locale.UK)
val second = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T01:30:00Z"), brussels, Locale.UK)
assertThat(first).startsWith("2:30 AM ")
assertThat(second).startsWith("2:30 AM ")
assertThat(first).isNotEqualTo(second)
}
@Test
fun `an unambiguous time stays bare`() {
assertThat(withRepeatedHourZone("2:45 PM", Instant.parse("2026-10-25T13:45:00Z"), brussels, Locale.UK))
.isEqualTo("2:45 PM")
assertThat(withRepeatedHourZone("3:30 AM", Instant.parse("2026-10-25T02:30:00Z"), brussels, Locale.UK))
.isEqualTo("3:30 AM")
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -93,7 +94,7 @@ class RescheduleViewModelTest {
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return RescheduleViewModel(repo, dispatcher)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
@@ -115,6 +116,17 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
)
/** The end dragged an hour later, the start left where it is. */
private fun endOneHourLater() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
)
@Test
fun `a one-off drop writes straight through with no scope prompt`(
@TempDir tempDir: Path,
@@ -595,4 +607,82 @@ class RescheduleViewModelTest {
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
@Test
fun `a resize writes the new length and reports both ends`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
val (_, original, updated) = fake.updatedEvents.single()
assertThat(updated.start).isEqualTo(original.start)
assertThat(updated.end).isNotEqualTo(original.end)
val moved = vm.outcome.value as MoveOutcome.Moved
assertThat(moved.startMillis).isEqualTo(beginMillis)
assertThat(moved.endMillis).isEqualTo(endMillis + 3_600_000L)
assertThat(moved.undo).isNotNull()
}
@Test
fun `a moved event reports no end, so its chip keeps the move wording`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat((vm.outcome.value as MoveOutcome.Moved).endMillis).isNull()
}
@Test
fun `resizing a recurring event asks for the scope, as a resize`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = false, resize = true))
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
// The start stays put, so the rule needs no realigning.
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@Test
fun `pulling a recurring event's start back over midnight resizes only the occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
val sundayEvening = LocalDateTime(LocalDate(2026, 6, 7), LocalTime(22, 0))
.toInstant(TimeZone.currentSystemDefault())
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
),
)
advanceUntilIdle()
// Not a whole-day shift, so the series anchor can't follow.
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
}
}
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/** Where a dragged resize handle leaves an event's ends (#141). */
class ResizedSpanTest {
// 09:00–10:00, in minutes from its day's midnight.
private val start = 9 * 60
private val end = 10 * 60
@Test
fun `the bottom edge snaps and leaves the start alone`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
.isEqualTo(start to 11 * 60 + 15)
}
@Test
fun `the top edge snaps and leaves the end alone`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
.isEqualTo(8 * 60 to end)
}
@Test
fun `an edge stops one snap step short of the other end`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
}
@Test
fun `the bottom edge runs on past midnight`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
.isEqualTo(start to 24 * 60 + 90)
}
@Test
fun `the top edge runs back into the day before`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
.isEqualTo(-60 to end)
}
@Test
fun `wall clock minutes past either midnight land on the neighbouring day`() {
val zone = TimeZone.of("Europe/Berlin")
val day = LocalDate(2026, 10, 9)
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
assertThat(wallClockInstant(day, -30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
}
@Test
fun `a finger drifting just over a column border keeps the edge on its day`() {
assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2)
assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2)
}
@Test
fun `a finger well into the next column takes the edge there`() {
assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3)
assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1)
}
@Test
fun `the first column is the one under the finger`() {
assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4)
assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6)
}
@Test
fun `a finger resting between two slots keeps the one it is on`() {
// 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave.
assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60)
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
}
@Test
fun `an edge dragged into a day past the other end changes nothing`() {
// The start into the day after a same-day event ends.
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
.isEqualTo(start to end)
// The end into the day before it starts.
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
.isEqualTo(start to end)
}
@Test
fun `an edge in the event's own days still clamps against the other end`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
// An overnight event's start may move anywhere up to its end's day.
val overnightEnd = 24 * 60 + 2 * 60
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
.isEqualTo(24 * 60 + 60 to overnightEnd)
}
}
@@ -2,14 +2,18 @@ package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/**
@@ -153,4 +157,135 @@ class TimelineDropTest {
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
private val brussels = TimeZone.of("Europe/Brussels")
private val fallBack = LocalDate(2026, 10, 25)
@Test
fun `a block on the fall-back day carries no clip offset`() {
// #387: 13:45Z is 14:45 CET, drawn and picked up at 14:45.
val event = overnight.copy(
start = Instant.parse("2026-10-25T13:45:00Z"),
end = Instant.parse("2026-10-25T15:45:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@Test
fun `a tail on the fall-back day is offset by wall-clock time`() {
val event = overnight.copy(
start = LocalDate(2026, 10, 24).atTime(22, 0).toInstant(brussels),
end = fallBack.atTime(6, 0).toInstant(brussels),
)
val tail = layoutDay(listOf(event), fallBack, brussels).single()
val offset = tail.clipOffsetMinutes(fallBack, brussels)
assertThat(offset).isEqualTo(2 * 60)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@Test
fun `wall-clock span follows the clock change`() {
// 01:00 to 04:00 on the fall-back day is four real hours.
assertThat(wallClockSpanMinutes(fallBack, 60, 4.hours, brussels)).isEqualTo(3 * 60)
// 01:00 to 04:00 on the spring-forward day is two.
assertThat(wallClockSpanMinutes(LocalDate(2026, 3, 29), 60, 2.hours, brussels)).isEqualTo(3 * 60)
assertThat(wallClockSpanMinutes(sat, 20 * 60, 12.hours, zone)).isEqualTo(12 * 60)
}
@Test
fun `a drop in the repeated fall-back hour keeps the event's offset`() {
// 02:30 CET, the second 02:30 of the day, dragged 15 minutes later.
val event = overnight.copy(
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T02:30:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@Test
fun `an ambiguous time without an offset to keep takes the earlier one`() {
assertThat(wallClockInstant(fallBack, 2 * 60 + 45, brussels))
.isEqualTo(Instant.parse("2026-10-25T00:45:00Z"))
}
@Test
fun `a start in the spring-forward gap resolves an hour on`() {
val springForward = LocalDate(2026, 3, 29)
val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, kept = brussels.repeatedHourOffsetAt(overnight.start))
assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30)
assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60)
}
@Test
fun `a span ending in the repeated fall-back hour reaches that hour's end`() {
// 02:00 CEST plus one hour is 02:00 CET; 02:30 CEST plus one is 02:30 CET.
assertThat(wallClockSpanMinutes(fallBack, 2 * 60, 1.hours, brussels)).isEqualTo(60)
assertThat(wallClockSpanMinutes(fallBack, 2 * 60 + 30, 1.hours, brussels)).isEqualTo(30)
}
@Test
fun `a drop onto the repeated fall-back hour doesn't depend on the event's season`() {
val slot = 2 * 60 + 30
val first = Instant.parse("2026-10-25T00:30:00Z")
val july = Instant.parse("2026-07-01T10:00:00Z")
val november = Instant.parse("2026-11-10T10:00:00Z")
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(july))).isEqualTo(first)
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(november))).isEqualTo(first)
// Later the same day, after the clocks went back: still no say.
val evening = Instant.parse("2026-10-25T18:00:00Z")
assertThat(wallClockInstant(fallBack, slot, brussels, kept = brussels.repeatedHourOffsetAt(evening))).isEqualTo(first)
}
@Test
fun `a bottom resize into the repeated hour keeps a second-pass start's pass`() {
// 02:30 CET (the second pass) to 04:00, its end pulled up to 02:45.
val event = overnight.copy(
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
assertThat(drop.start).isEqualTo(event.start)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@Test
fun `a resized edge keeps its own pass over the other edge's`() {
// 02:30 CEST (the first pass) to 02:15 CET (the second), the start pulled to 02:00.
val event = overnight.copy(
start = Instant.parse("2026-10-25T00:30:00Z"),
end = Instant.parse("2026-10-25T01:15:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
assertThat(drop.end).isEqualTo(event.end)
}
@Test
fun `a bottom resize up the repeated hour shortens the event`() {
// 02:20 CEST to 02:10 CET is drawn 02:20 to 03:00; its end pulled up to 02:50.
val event = overnight.copy(
start = Instant.parse("2026-10-25T00:20:00Z"),
end = Instant.parse("2026-10-25T01:10:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@Test
fun `a bottom resize to a time the grid draws once lands there`() {
// 01:00 CEST to 04:00 CET, its end pulled up to 02:15: only the first pass
// is drawn at 02:15, the second would be drawn down at 03:00.
val event = overnight.copy(
start = Instant.parse("2026-10-24T23:00:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
}
}
@@ -0,0 +1,217 @@
package de.jeanlucmakiola.calendula.ui.detail
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The fitting applied on top of the platform matcher. What the matcher itself
* finds is covered on a device by PhoneLinksPlatformTest.
*/
class PhoneLinksTest {
/** Refine a match the platform found at [found] in [text]. */
private fun refine(text: String, found: String): List<PhoneLink> {
val start = text.indexOf(found)
return refinePhoneMatches(
text,
listOf(PhoneMatch(start, start + found.length)),
findWebLinks(text),
)
}
@Test
fun `a match keeps its span and dials only digits`() {
val text = "Call Anna on 0151 234-5678 before"
val link = refine(text, "0151 234-5678").single()
assertThat(link.number).isEqualTo("01512345678")
assertThat(text.substring(link.start, link.end)).isEqualTo("0151 234-5678")
}
@Test
fun `an international number keeps its plus`() {
assertThat(refine("Tel. +49 30 1234567", "+49 30 1234567").single().number)
.isEqualTo("+49301234567")
}
@Test
fun `the trunk zero of an international number is dropped`() {
assertThat(refine("+49 (0) 151 2345678", "+49 (0) 151 2345678").single().number)
.isEqualTo("+491512345678")
}
@Test
fun `a country code stranded before the trunk zero is pulled back in`() {
val text = "Mobil: +49 (0)151 2345678"
val link = refine(text, "151 2345678").single()
assertThat(link.number).isEqualTo("+491512345678")
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 (0)151 2345678")
}
@Test
fun `a conference dial-in keeps its pauses and meeting id`() {
val text = "One tap mobile: +16465588656,,85012345678#,,,,*123456# (US)"
val link = refine(text, "+16465588656").single()
assertThat(link.number).isEqualTo("+16465588656,,85012345678#,,,,*123456#")
assertThat(text.substring(link.start, link.end))
.isEqualTo("+16465588656,,85012345678#,,,,*123456#")
}
@Test
fun `meeting ids and PINs are not numbers to call`() {
assertThat(refine("Meeting ID: 850 1234 5678", "850 1234 5678")).isEmpty()
assertThat(refine("Conference ID: 123 456 789#", "123 456 789")).isEmpty()
assertThat(refine("PIN: 123 456 789#", "123 456 789")).isEmpty()
assertThat(refine("Konferenz-ID: 123 456 789#", "123 456 789")).isEmpty()
assertThat(refine("Access code: 2345 678 9012", "2345 678 9012")).isEmpty()
}
@Test
fun `a label on an earlier line does not hide a number`() {
assertThat(refine("Meeting ID: 850 1234 5678\nDial +1 646 558 8656", "+1 646 558 8656"))
.hasSize(1)
}
@Test
fun `a label earlier on the same line does not hide a later number`() {
assertThat(refine("PIN 1234, call +1 646 558 8656", "+1 646 558 8656")).hasSize(1)
}
@Test
fun `a match holding a date is not a number`() {
assertThat(refine("Am 14.10.2026 10:00 Uhr", "14.10.2026 10")).isEmpty()
assertThat(refine("2026-10-14 10:00", "2026-10-14 10")).isEmpty()
assertThat(refine("10/14/2026 9:00", "10/14/2026 9")).isEmpty()
assertThat(refine("14.10.26 - 16.10.26", "14.10.26 - 16.10.26")).isEmpty()
}
@Test
fun `amounts are not numbers`() {
assertThat(refine("Budget 1.250.000 EUR", "1.250.000")).isEmpty()
assertThat(refine("Budget 1 250 000 EUR", "1 250 000")).isEmpty()
}
@Test
fun `an area code in threes is still a number`() {
assertThat(refine("030 123 456", "030 123 456")).hasSize(1)
}
@Test
fun `numbers in dotted pairs are not dates`() {
assertThat(refine("01.23.45.67.89", "01.23.45.67.89").single().number)
.isEqualTo("0123456789")
assertThat(refine("555.123.4567", "555.123.4567")).hasSize(1)
}
@Test
fun `a stray count after the number is trimmed`() {
val text = "Call +49 151 2345678 2 times"
val link = refine(text, "+49 151 2345678 2").single()
assertThat(link.number).isEqualTo("+491512345678")
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 151 2345678")
}
@Test
fun `short trailing groups of a real number stay`() {
assertThat(refine("030 123 45 67", "030 123 45 67").single().number).isEqualTo("0301234567")
}
@Test
fun `spaced threes without a currency are a number`() {
assertThat(refine("Móvil 612 345 678", "612 345 678").single().number).isEqualTo("612345678")
}
@Test
fun `a trailing pair after a short group stays`() {
val text = "Büro 0221 1234 56"
val link = refine(text, "0221 1234 56").single()
assertThat(link.number).isEqualTo("0221123456")
assertThat(text.substring(link.start, link.end)).isEqualTo("0221 1234 56")
}
@Test
fun `an extension becomes a pause`() {
assertThat(refine("+1 212 555 1234 ext. 567", "+1 212 555 1234 ext. 567").single().number)
.isEqualTo("+12125551234,567")
assertThat(refine("+1 212 555 1234 x567", "+1 212 555 1234 x567").single().number)
.isEqualTo("+12125551234,567")
}
@Test
fun `postcodes and other short runs are not numbers`() {
assertThat(refine("Hauptstraße 12, 10115 Berlin", "10115")).isEmpty()
assertThat(refine("Raum 123456", "123456")).isEmpty()
}
@Test
fun `a Webex access code is not a number to call`() {
assertThat(refine("Meeting number (access code): 2732 123 4567", "2732 123 4567")).isEmpty()
}
@Test
fun `a conference number is a number to call`() {
assertThat(refine("Conference number: +49 30 1234567", "+49 30 1234567")).hasSize(1)
assertThat(refine("Konferenznummer: +49 30 1234567", "+49 30 1234567")).hasSize(1)
}
@Test
fun `a comma-separated list stays separate numbers`() {
val text = "Tel 030 1234567,0151 2345678"
val first = text.indexOf("030")
val second = text.indexOf("0151")
val links = refinePhoneMatches(
text,
listOf(PhoneMatch(first, first + 11), PhoneMatch(second, text.length)),
emptyList(),
)
assertThat(links.map { it.number }).containsExactly("0301234567", "01512345678").inOrder()
}
@Test
fun `a single-comma suffix closed by a hash is a dial-in`() {
assertThat(refine("+1 555 123 4567,123456#", "+1 555 123 4567").single().number)
.isEqualTo("+15551234567,123456#")
}
@Test
fun `labels behind invisible spacing still hide an id`() {
assertThat(refine("PIN: \u202A123 456 789\u202C#", "123 456 789")).isEmpty()
assertThat(refine("Meeting ID:\u00A0850 1234 5678", "850 1234 5678")).isEmpty()
}
@Test
fun `dotted times of day are not numbers`() {
assertThat(refine("Beginn 9.30 – 12.00 Uhr", "9.30 – 12.00")).isEmpty()
assertThat(refine("10.00-11.30", "10.00-11.30")).isEmpty()
}
@Test
fun `a trailing digit with no word after it stays`() {
assertThat(refine("Zentrale 030 123456 0", "030 123456 0").single().number)
.isEqualTo("0301234560")
assertThat(refine("0221 9876543 21\nnext line", "0221 9876543 21").single().number)
.isEqualTo("0221987654321")
}
@Test
fun `digits in a URL without its scheme are not a number`() {
assertThat(refine("zoom.us/j/85012345678", "85012345678")).isEmpty()
assertThat(refine("room?id=0301234567", "0301234567")).isEmpty()
}
@Test
fun `dotted threes without a currency are a number`() {
assertThat(refine("Móvil 612.345.678", "612.345.678")).hasSize(1)
assertThat(refine("Budget 125.000.000 €", "125.000.000")).isEmpty()
}
@Test
fun `digits inside a web link are not a number`() {
assertThat(refine("https://x.example/030-1234567", "030-1234567")).isEmpty()
}
@Test
fun `a number beside a web link is still found`() {
assertThat(refine("https://zoom.us/j/85012345678 or dial +1 646 558 8656", "+1 646 558 8656"))
.hasSize(1)
}
}
@@ -1,31 +1,42 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.content.ContextWrapper
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.endInstant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -59,11 +70,16 @@ class EventEditViewModelTest {
syncsEvents = syncsEvents,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
private fun detail(
calendarId: Long,
rrule: String? = null,
start: Instant = Instant.fromEpochMilliseconds(beginMillis),
end: Instant = Instant.fromEpochMilliseconds(endMillis),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
start = start,
end = end,
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
@@ -92,10 +108,15 @@ class EventEditViewModelTest {
// Passed in by the tests that need to seed a setting first; a second
// DataStore on the same file would clash, so it is built only once.
s: SettingsPrefs = settings(tempDir),
zones: FakeZoneSource = FakeZoneSource(),
// A queued dispatcher here holds provider reads and writes until the test
// advances, so it can close the form while one is in flight.
io: CoroutineDispatcher = dispatcher,
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
val repo = CalendarRepositoryImpl(fake, p, s, io)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
@@ -221,12 +242,12 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Calendar 1 has no length of its own, so it inherits the global 30 min.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
job.cancel()
}
@@ -246,7 +267,7 @@ class EventEditViewModelTest {
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
job.cancel()
}
@@ -267,7 +288,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// The file owns the length; a calendar switch must not restretch it.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
job.cancel()
}
@@ -288,15 +309,15 @@ class EventEditViewModelTest {
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
job.cancel()
}
private fun importedForm(end: LocalDateTime) = EventForm(
calendarId = null,
title = "Imported",
start = LocalDateTime(2030, 1, 15, 9, 0),
end = end,
start = FormTime(LocalDateTime(2030, 1, 15, 9, 0)),
end = FormTime(end),
)
@Test
@@ -316,7 +337,7 @@ class EventEditViewModelTest {
advanceUntilIdle()
// Dates carry no length: the times stay where the form put them.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
assertThat(vm.state.value?.form?.end?.dateTime).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
job.cancel()
}
@@ -407,4 +428,310 @@ class EventEditViewModelTest {
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
}
@Test
fun `an edit counts as unsaved only while it differs from the loaded event`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Retro")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
vm.setTitle("Standup")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@Test
fun `a new form holding only its defaults has nothing to lose`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Lunch")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
vm.setTitle("")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@Test
fun `deleting from the form removes the event and reports it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
vm.delete(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
job.cancel()
}
@Test
fun `deleting an event that is already gone counts as deleted`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
fake.writeError = NoSuchEventException(42L)
vm.delete(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
job.cancel()
}
@Test
fun `a delete without write access reports it as a delete`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
fake.writeError = SecurityException()
vm.delete(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.DeleteDenied)
job.cancel()
}
@Test
fun `a blank rrule does not make the edited event recurring`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, rrule = "") }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.editedEventRecurs()).isFalse()
job.cancel()
}
@Test
fun `accepting the default reminders for an import counts as a change`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultReminderMinutes(listOf(15))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)), ImportSource.File)
advanceUntilIdle()
assertThat(vm.importReminderPrompt.value).isNotNull()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.applyImportedReminderDefault()
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
job.cancel()
}
@Test
fun `a drafted block opens at its own instants across the fall-back hour`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
// An hour from 02:30 CEST ends at 02:30 CET, the same wall clock.
vm.openDraft(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(form.problems(berlin)).isEmpty()
job.cancel()
}
@Test
fun `a drafted block on the second pass stays on it at its length`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setCalendarEventDuration(1L, 480)
val vm = viewModel(tempDir, fake, s, FakeZoneSource(berlin))
val job = activate(vm)
// 02:30 CET to 03:00 CET: the default length mustn't stretch it.
vm.openDraft(Instant.parse("2026-10-25T01:30:00Z"), Instant.parse("2026-10-25T02:00:00Z"))
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(form.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T02:00:00Z"))
job.cancel()
}
@Test
fun `a time picked in the repeated hour and back keeps the opened pass`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Opened at 02:30 CET (the second pass) to 03:30, its start moved away and back.
val begin = Instant.parse("2026-10-25T01:30:00Z")
val end = Instant.parse("2026-10-25T02:30:00Z")
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
val opened = vm.state.value!!.form!!
vm.setStartTime(LocalTime(4, 0))
vm.setStartTime(LocalTime(2, 30))
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `all-day on and off keeps a second-pass event where it was`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val begin = Instant.parse("2026-10-25T01:30:00Z")
val end = Instant.parse("2026-10-25T02:30:00Z")
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L, start = begin, end = end) }
}
val vm = viewModel(tempDir, fake, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = begin.toEpochMilliseconds(), endMillis = end.toEpochMilliseconds())
advanceUntilIdle()
val opened = vm.state.value!!.form!!
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
@Test
fun `a load abandoned by closing the form never lands on the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { id ->
val base = detail(calendarId = 1L)
base.copy(instance = base.instance.copy(eventId = id, title = "Event $id"))
}
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = beginMillis, endMillis = endMillis)
vm.reset()
advanceUntilIdle()
vm.openForEdit(eventId = 2L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.form?.title).isEqualTo("Event 2")
job.cancel()
}
@Test
fun `a failed load abandoned by closing the form doesn't close the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { null }
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = beginMillis, endMillis = endMillis)
vm.reset()
advanceUntilIdle()
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(vm.loadFailed.value).isFalse()
job.cancel()
}
@Test
fun `a delete still running when the form closes doesn't report to the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
vm.delete(RecurringWriteScope.AllEvents)
vm.reset()
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
}
@@ -190,5 +190,121 @@ class WeekLayoutTest {
assertThat(spans.all { it.lane == 0 }).isTrue()
}
// Europe/Brussels falls back on 2026-10-25 (25 h) and springs forward on 2026-03-29 (23 h).
private val brussels = TimeZone.of("Europe/Brussels")
private fun brusselsEvent(date: LocalDate, from: Pair<Int, Int>, to: Pair<Int, Int>) = event(
date.atTime(from.first, from.second).toInstant(brussels),
date.atTime(to.first, to.second).toInstant(brussels),
)
@Test
fun `timed block sits at its wall-clock time on the fall-back day`() {
val day = LocalDate(2026, 10, 25)
// The event from #387: 13:45Z is 14:45 CET.
val ev = event(Instant.parse("2026-10-25T13:45:00Z"), Instant.parse("2026-10-25T15:45:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
}
@Test
fun `timed block sits at its wall-clock time on the spring-forward day`() {
val day = LocalDate(2026, 3, 29)
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
}
@Test
fun `late block on the fall-back day is not cut short`() {
val day = LocalDate(2026, 10, 25)
val b = layoutDay(listOf(brusselsEvent(day, 22 to 30, 23 to 59)), day, brussels).single()
assertThat(b.startMin).isEqualTo(22 * 60 + 30)
assertThat(b.endMin).isEqualTo(23 * 60 + 59)
}
@Test
fun `block running past midnight on the fall-back day reaches the day's end`() {
val day = LocalDate(2026, 10, 25)
val ev = event(
day.atTime(23, 0).toInstant(brussels),
day.plusDays(1).atTime(1, 0).toInstant(brussels),
)
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(23 * 60)
assertThat(b.endMin).isEqualTo(MINUTES_PER_DAY)
}
@Test
fun `instant event on the fall-back day sits at its wall-clock time`() {
val day = LocalDate(2026, 10, 25)
val at = day.atTime(9, 0).toInstant(brussels)
val b = layoutDay(listOf(event(at, at)), day, brussels).single()
assertThat(b.startMin).isEqualTo(9 * 60)
assertThat(b.endMin).isEqualTo(9 * 60)
}
@Test
fun `block ending in the repeated fall-back hour is drawn to that hour's end`() {
val day = LocalDate(2026, 10, 25)
// 02:00 CEST to 02:00 CET: one real hour, the same wall-clock time at both ends.
val ev = event(Instant.parse("2026-10-25T00:00:00Z"), Instant.parse("2026-10-25T01:00:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(2 * 60)
assertThat(b.endMin).isEqualTo(3 * 60)
}
@Test
fun `block ending in the repeated fall-back hour leaves the next hour free`() {
val day = LocalDate(2026, 10, 25)
// 02:15 CEST to 02:15 CET, then 03:00 CET: an hour apart in real time.
val repeat = event(Instant.parse("2026-10-25T00:15:00Z"), Instant.parse("2026-10-25T01:15:00Z"))
val next = event(Instant.parse("2026-10-25T02:00:00Z"), Instant.parse("2026-10-25T03:00:00Z"))
val blocks = layoutDay(listOf(repeat, next), day, brussels)
assertThat(blocks.map { it.startMin to it.endMin })
.containsExactly(2 * 60 + 15 to 3 * 60, 3 * 60 to 4 * 60)
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `minutesPast reads wall clock either side of the day`() {
val day = LocalDate(2026, 10, 25)
assertThat(Instant.parse("2026-10-24T21:00:00Z").minutesPast(day, brussels)).isEqualTo(-60)
assertThat(Instant.parse("2026-10-25T13:45:00Z").minutesPast(day, brussels)).isEqualTo(14 * 60 + 45)
assertThat(Instant.parse("2026-10-25T23:30:00Z").minutesPast(day, brussels))
.isEqualTo(MINUTES_PER_DAY + 30)
}
@Test
fun `a block ending in the repeated fall-back hour is labelled with its own end`() {
val day = LocalDate(2026, 10, 25)
// 02:30 CEST to 02:30 CET: drawn to the repeated hour's end, but it ends at 02:30.
val ev = event(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.endMin).isEqualTo(3 * 60)
assertThat(b.labelEndMin(day, brussels)).isEqualTo(2 * 60 + 30)
}
@Test
fun `an ordinary block is labelled with its drawn end`() {
val day = LocalDate(2026, 10, 25)
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
assertThat(b.labelEndMin(day, brussels)).isEqualTo(b.endMin)
}
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
@Test
fun `a longer run into the repeated fall-back hour is drawn no shorter`() {
val day = LocalDate(2026, 10, 25)
// 02:10 CEST to 02:05 CET (55 min) and to 02:20 CET (70 min): both to 03:00.
val shorter = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:05:00Z"))
val longer = event(Instant.parse("2026-10-25T00:10:00Z"), Instant.parse("2026-10-25T01:20:00Z"), id = 2L)
assertThat(layoutDay(listOf(shorter), day, brussels).single().endMin).isEqualTo(3 * 60)
assertThat(layoutDay(listOf(longer), day, brussels).single().endMin).isEqualTo(3 * 60)
// A run that starts on the second pass reads its plain wall clock.
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
}
}
+5 -1
View File
@@ -119,7 +119,11 @@ gesture finishes from its scaled preview.
switch only changes what they say.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap.
like an event tap. With the "tapping an event opens" setting on the edit form
(#178), a tap in the views or search opens the edit overlay directly for events
in writable calendars; notifications and widgets always open the detail. The
edit form guards its own back: unsaved changes ask to save, discard or keep
editing.
### First-launch wizard