Compare commits

...
19 Commits
Author SHA1 Message Date
makiolaj 49cb11fe09 Share the host overlays' slide, held key and create request 2026-10-16 14:07:39 +02:00
makiolaj 32668623a6 Hold the editor's open picker in one slot and split up the form 2026-10-16 14:07:39 +02:00
makiolaj 6e673a1cec Share the drag controller's piece building, settling, column flip and slot snap 2026-10-16 14:07:39 +02:00
makiolaj 11c1b71c85 Hand external requests to the host as one holder 2026-10-16 14:07:39 +02:00
makiolaj 28c4e68de4 Bundle the timeline's shared scroll, drag and callbacks into TimelineActions 2026-10-16 14:07:39 +02:00
makiolaj 7a43ee0595 Share one body for the editor's picker cards 2026-10-16 14:07:39 +02:00
makiolaj 3ed12f1fee One rollover-following today flow for the view models 2026-10-16 14:07:39 +02:00
makiolaj 16fe2687e8 Drop the editor's pass-through wrappers 2026-10-16 14:07:39 +02:00
Jean-Luc Makiolaandmakiolaj f55cfffa3a Extract repeated helpers in data, domain and UI layers (#416)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Step 6 of the simplification review: mechanical helper extractions, one commit per group. Widget code is untouched.

**Data layer** (CalendarDataSource and the mappers)
- `updateEventRow`/`eventUri` replace the seven "update, throw if no row" copies and the loose `withAppendedId` spellings.
- `SeriesRow.timeColumns()` / `exdateValues()` define the DTSTART/RRULE/DURATION/TZ/ALL_DAY set once for truncate, split, detach, delete and exception copy.
- One `insertReminderRow` for the four reminder loops, `insertLocalCalendar` for local and managed creates, `applyManagedCalendarReminders` iterates `queryManagedEvents`, `reconcileAttendees` reuses `insertAttendees`, plus `mapRows`, `instancesUri`, `mintUid`, shared `eventColorColumns` and `switchedOffCalendarIds`.
- The read-side mappers share `ColumnReader` helpers (end time, status, all-day UTC date); each keeps its own end-time clamp. `importDuration` goes through `rfc2445Duration` with identical output. The `eventColorPalette` data-source member is gone; the repository calls the pure function.
- One behaviour difference: a provider *exception* while inserting a reminder row was only swallowed on import. It is now logged and skipped on every path (create, update, reconcile, move), as the existing comments already describe it, so it no longer fails the event write.

**Domain**
- `BasicFormat.kt` replaces the four `%04d…` formatters and three substring parsers (ICS writer/parser, Recurrence, RecurrenceText). Parsing stays as lenient as before.
- `rruleParts()` for the RRULE tokenising in five places; each caller keeps its own malformed-part handling. `rruleTruncatedAt` keeps its own tokeniser since it preserves garbage parts verbatim.
- `allDayFormTimes` and `normalizedRrule` for the three form-prefill copies, `zoneOrNull` for the eight zone guards, EventSearch orders months by `YearMonth`, IcsParser uses duration arithmetic and `firstOrNull`.
- FREQ matching no longer depends on the device locale (a lowercase rule failed to parse on Turkish-locale phones).
- Skipped: the contact-date parser rewrite (tests don't pin enough inputs) and the writer-side enum tables (their exhaustive `when` is a useful compile check).

**UI / view models**
- `dayRange(first, last, zone)` and one `startOfWeek` replace four start..end-of-day helpers and two identical week-start functions.
- `orUnnamed()` replaces 17 `ifBlank { calendar_unnamed }` spellings; a shared `CalendarVisibilitySwitch` and `CalendarManagerRow`.
- Filter uses `CalendarAccountGroup` plus a hidden-id set instead of its own copy of the grouping model; the grouping test moved to `AccountGroupsTest`.
- `agendaDays` is shared by the Agenda screen and the split-month pane.
- kotlinx-datetime's `yearMonth`, `firstDay`/`lastDay` and java.time converters replace about 20 hand-written bridges; one `formatTimeOfDay` for agenda rows and month chips.
- No visible UI change.

Net about −240 lines.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/416
2026-10-16 13:56:17 +02:00
Jean-Luc Makiolaandmakiolaj bb8c91acc5 Move settings storage onto floret's PrefStore (#415)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Step 5 of the simplification review (preferences pipeline). Needs floret-kit#15 merged first; the submodule pin (3b376ac) is on that PR's branch, not on floret main yet.

- **SettingsPrefs, CalendarPrefs, ReminderStatePrefs** read and write through floret core-prefs (new dependency). Every setting is one `Pref` in a new `SettingsKeys` object, so each flow and setter is a single line. Stored key names, defaults and clamping are unchanged; the old and new key lists diff clean. Flows are now distinct-until-changed, so an unrelated key's write no longer re-emits them.
- **ThemeMode** is floret's (same values, same `theme_mode`/`dynamic_color` keys). MainActivity uses floret's `isDark`/`isPureBlack` instead of its two hand-written `when`s.
- **Onboarding flags**: the eight per-step flow/setter pairs are one `OnboardingStep` → pref map, with `onboardingProgress` and `setOnboardingDone(step, done)`. Same key names. OnboardingViewModel loses its three carrier classes and the back-navigation `when`.
- **SettingsViewModel** builds `SettingsUiState` from one prefs snapshot instead of the four-level nested `combine` and its seven carrier classes; the font and special-dates states do the same. The 30 one-line setters use a `save {}` helper. Setters with side effects (widgets, scan, scheduler) keep their bodies; the widget refresh code is untouched.

Net about −550 lines. Unit tests and lint pass; the prefs tests now seed raw on-disk key names instead of the removed internal key constants.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/415
2026-10-16 13:17:49 +02:00
Jean-Luc Makiolaandmakiolaj b627ed04b7 Move onto floret's shared rows, onboarding pieces and Android helpers (#410)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Stacked on #409 (its commit shows here until it merges). Bumps floret-kit to 5093e1c (floret main, floret-kit#14) and deletes the local copies of what moved there. Widget code untouched.

- `GroupedRow(checked)` for the check-mark rows; `GroupedSwitchRow` for 16 switch rows; `OptionRows` in the three settings preview pickers; `CustomExpandRow` for the three "Custom" picker rows
- `IconInfoCard` replaces `EditCard` and `DetailCard`; `GroupedHint` replaces `SettingsHint` / `HintText` / `PickerDescription`
- Onboarding steps use `OnboardingScaffold(headline, supporting)` and `OnboardingPrimaryButton`
- `LanguagePickerRow(header, predictiveBack)` replaces the local `LanguageRow`
- `relativeTimeAgo`, `ReminderOverride.minutesOrEmpty()`
- New `core-android` dependency: `goAsync { }` in the reminder receivers, `hasPermission` for all non-widget permission checks, `broadcastTicks` behind TodaySource / ZoneSource, `setWakeup` in the reminder and snooze alarms, `stateWhileSubscribed` for the 39 `WhileSubscribed(5_000)` sites

One visible change: the expand rows in Event form and Notification settings show floret's rotating chevron instead of swapping ExpandLess/ExpandMore.

Kept local on purpose: the Reliable delivery row (no selected highlight today), the Dynamic colour switch (isn't dimmed when unavailable), HiddenCalendarsStep's headline (needs its inset padding) and ViewStep's onboarding block (headlineSmall).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/410
2026-10-15 23:06:26 +02:00
Jean-Luc Makiolaandmakiolaj d8e3250af9 Share test event builders and repository wiring (#408)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Step 3 of the simplification review. Tests only, no production code changes.

- `TestEvents.kt`: defaulted `testEvent` / `allDayEvent` / `timedEvent` / `testDetail` builders replace the full `EventInstance(...)` / `EventDetail(...)` spellings and the per-file `allDay`/`timed` helpers. Helpers with test-specific titles, colours or zones stay as one-line wrappers, so every built event is unchanged.
- `TestRepositories.kt`: `testDataStore` / `testPrefs` / `testSettings` / `testRepository` plus a JUnit 5 `MainDispatcherExtension` replace the repeated DataStore, repository and setMain/resetMain wiring.

Same 1302 tests before and after; widget tests untouched.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/408
2026-10-15 16:37:46 +02:00
Jean-Luc Makiolaandmakiolaj 8bd2ac7a9a Remove dead code and unused imports (#407)
Beta — Codeberg pre-release / detect (push) Successful in 51s
Beta — Codeberg pre-release / beta (push) Skipped
First step of the simplification review. No behaviour change.

- Unreachable paged-month path (`MonthContent` / `MonthPager` / `MonthPage`); the month branch is now scrolling vs split
- `ui/day/` and its test, `MonthViewModel.anchorPage`, `PagedTimelineViewModel.goToDate`, `SettingsPrefs.setQuickSwitchConfig`, `TimelineHours.span`
- `PermissionViewModel` / `PermissionUiState`, replaced by a saveable `denied` flag in `PermissionScreen`
- `ReminderNotifier.post` no longer returns a value nobody read
- 38 unused imports outside the widget code

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/407
2026-10-15 16:36:08 +02:00
Jean-Luc Makiolaandmakiolaj c1c529d4bd Keep deleted and edited occurrences when moving a series (#404)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Moving a recurring event to another calendar re-inserted its exception rows under the new event. That event has no `_sync_id` yet, so the rows never attached: deleted occurrences came back, and edited ones showed twice (original plus the edited copy).

The move now does what delete and edit-this-occurrence already do for unsynced series: cancelled occurrences become EXDATE entries on the moved series, edited ones become EXDATE plus a standalone event on the target calendar. The standalone copies are rolled back with the rest if the move fails.

`buildCopiedExceptionValues` is gone with its tests, and `ExceptionRowSnapshot`/`ExceptionProjection` only read the columns still used.

Adds `MoveEventExceptionsTest` (instrumented), which fails on the old code and passes now.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/404
2026-10-15 16:25:34 +02:00
Jean-Luc Makiolaandmakiolaj aca048e593 Open the until-date picker on today's local date (#405)
Beta — Codeberg pre-release / detect (push) Successful in 10s
Beta — Codeberg pre-release / beta (push) Skipped
The repeat-until picker defaulted to today's UTC date, so near midnight it opened on yesterday or tomorrow depending on the zone. It now uses the device zone's today.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/405
2026-10-15 16:25:12 +02:00
Jean-Luc Makiolaandmakiolaj 440f85d89f Predictive back for the export calendar picker (#406)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
ExportCalendarPicker was the only full-screen picker without `predictiveBack = true`.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/406
2026-10-15 16:24:55 +02:00
Jean-Luc Makiolaandmakiolaj bf2e59a14e Update the selection bar's times live while moving or resizing (#141) (#403)
Beta — Codeberg pre-release / detect (push) Successful in 9s
Beta — Codeberg pre-release / beta (push) Skipped
Feedback from #141: when resizing with the left hand, the finger covers both the event's own time and the hour-column badge.

While a selected event is moved or resized, the time under its title in the top bar now follows the gesture. It shows what letting go would save, using the same calculation as the drop (real duration for a move, the untouched edge kept for a resize, DST-safe), and stays on the saved times until the event leaves its starting slot. It only recalculates when the snapped slot changes.

Closes #141

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/403
2026-10-15 16:21:25 +02:00
Jean-Luc Makiolaandmakiolaj 19f0dcae5d Say Create instead of Save when the editor makes a new event (#141) (#402)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Feedback from the #141 beta: a duplicate opened the editor with the same title and a plain Save button, so it looked like editing the original.

The editor button now says Create for every new event (new, duplicate, shared text, .ics, long-press draft) and Save only when editing an existing event. The label is taken from how the editor was opened, so it doesn't flip while the form loads.

The other two feedback points (handle shape, start time under the finger) are left as they are, see the comment on #141.

Closes #141

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/402
2026-10-15 14:42:39 +02:00
Jean-Luc Makiolaandmakiolaj c3401ed61c Add haptics to split-pane swipes and event drops (#116) (#401)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
- Swiping between days in the split month pane now ticks, like pulling the pane open and closed already did.
- Dropping a moved or resized event gives a confirm buzz. It only fires on a real drop; putting an event back where it was, cancelling, or undoing stays silent.
- Fixes a #141 flicker: after a resize, the block dimmed briefly as the floating copy faded out. It now takes over at full opacity, as a block released in place already did.

Deviation from the issue: the original ask (haptics when swiping the split view up and down) shipped in 2.22.0, so this covers the remaining gaps from the analysis. Month and week paging stays silent on purpose, and settings switches are left for a later floret-kit change.

Closes #116

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/401
2026-10-15 14:22:47 +02:00
160 changed files with 3750 additions and 5934 deletions
+3
View File
@@ -175,7 +175,10 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-locale") implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash") implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-reminders") implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:components")
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Moving a synced series keeps its deleted and edited occurrences, although the
* moved row has no `_sync_id` for exception rows to attach to.
*/
@RunWith(AndroidJUnit4::class)
class MoveEventExceptionsTest {
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver = context.contentResolver
private val ds = AndroidCalendarDataSource(context)
private val zone = TimeZone.currentSystemDefault()
private val day = 86_400_000L
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
private val hour = 3_600_000L
/** "day offset:title" of every instance on [calendarId]. */
private fun occurrences(calendarId: Long): List<String> =
ds.instances(start - day, start + 10 * day)
.filter { it.calendarId == calendarId }
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
.sorted()
@Test
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
try {
val src = insertSyncedSeries(a)
ds.deleteOccurrence(src, start + 2 * day)
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
assertThat(occurrences(a)).isEqualTo(expected)
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
ds.moveEvent(src, b, form, form, 540)
assertThat(occurrences(a)).isEmpty()
assertThat(occurrences(b)).isEqualTo(expected)
} finally {
ds.deleteCalendar(a)
ds.deleteCalendar(b)
}
}
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
private fun insertSyncedSeries(calendarId: Long): Long {
val accountName = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
)!!.use { it.moveToFirst(); it.getString(0) }
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "series")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DURATION, "P3600S")
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
put(CalendarContract.Events._SYNC_ID, "move-test-series")
}
return ContentUris.parseId(resolver.insert(uri, values)!!)
}
}
@@ -29,11 +29,15 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
import de.jeanlucmakiola.calendula.ui.ExternalRequests
import de.jeanlucmakiola.calendula.ui.InsertRequest
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
@@ -65,9 +69,6 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -79,23 +80,12 @@ class MainActivity : AppCompatActivity() {
// picks up the in-app override instead of the night resource qualifier. // picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false private var systemBarsDark = false
// The occurrence a reminder notification was tapped for (eventId, begin, private val requests = ExternalRequests(
// end — the detail screen's key shape). singleTop + onNewIntent route a onExternalFormClosed = {
// tap into the running activity; CalendarHost consumes and clears it. insertFormOpen = false
private var requestedDetailKey by mutableStateOf<LongArray?>(null) if (launchedForInsert) finish()
},
// A navigation a home-screen widget asked for (open a date / start a )
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30) or shared text (#306).
// Consumed once by CalendarHost, which opens it in the create form for review.
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// Whether such a form is still open. Only then does a recreation re-read the // 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. // intent to bring it back; a saved or dismissed one stays closed.
@@ -105,11 +95,6 @@ class MainActivity : AppCompatActivity() {
// app that asked instead of leaving the calendar on top of it. // app that asked instead of leaving the calendar on top of it.
private var launchedForInsert = false private var launchedForInsert = false
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup // over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -137,11 +122,7 @@ class MainActivity : AppCompatActivity() {
// backdrop and bar icons instead of the night qualifier's (#181). // backdrop and bar icons instead of the night qualifier's (#181).
val cachedTheme = WindowThemeCache.read(this) val cachedTheme = WindowThemeCache.read(this)
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) } cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = when (cachedTheme) { systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
null, ThemeMode.SYSTEM -> systemDark
ThemeMode.LIGHT -> false
ThemeMode.DARK, ThemeMode.BLACK -> true
}
applyEdgeToEdge() applyEdgeToEdge()
lifecycleScope.launch { lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode -> settingsPrefs.themeMode.collect { mode ->
@@ -163,28 +144,24 @@ class MainActivity : AppCompatActivity() {
// it's still open. // it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch() val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) { if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull() requests.editKey = intent.editEventKeyOrNull()
} }
requestedNav = intent.navRequestOrNull() requests.nav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent } ?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull() requests.importUri = intent.importUriOrNull()
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore } requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requestedInsert != null insertFormOpen = requests.insert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT) launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requestedInsert != null) ?: (requests.insert != null)
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once. // and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle() val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (settings.themeMode) { val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK, ThemeMode.BLACK -> true
}
// onCreate can only see the night resource qualifier, not the in-app // onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow // override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice. // the app's light/dark choice.
@@ -223,7 +200,7 @@ class MainActivity : AppCompatActivity() {
CalendulaTheme( CalendulaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode == ThemeMode.BLACK, pureBlack = settings.themeMode.isPureBlack,
typography = typography, typography = typography,
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -236,21 +213,7 @@ class MainActivity : AppCompatActivity() {
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey, requests = requests,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -310,14 +273,14 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requests.importUri = it }
intent.createRequestOrNull()?.let { intent.createRequestOrNull()?.let {
requestedInsert = it requests.insert = it
insertFormOpen = true insertFormOpen = true
} }
intent.editEventKeyOrNull()?.let { requestedEditKey = it } intent.editEventKeyOrNull()?.let { requests.editKey = it }
} }
/** /**
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
@@ -28,6 +26,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.time.Clock import kotlin.time.Clock
@@ -133,8 +132,7 @@ class BackupWorker(
private fun notifyFailure(context: Context) { private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
PackageManager.PERMISSION_GRANTED
if (!canPost) return if (!canPost) return
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel( manager.createNotificationChannel(
@@ -1,13 +1,12 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.Manifest import android.Manifest
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
@@ -15,7 +14,6 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
@@ -28,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
@@ -36,9 +33,11 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -76,21 +75,13 @@ interface CalendarDataSource {
/** /**
* The event-colour palette the calendar's account publishes * The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped, * (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some * Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw * CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. * `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
*/ * picker shows it curated ([curatedForPicker]); matching a colour that came
fun eventColorPalette(calendarId: Long): List<EventColorOption> * from outside the account runs against this full set, since those keys are
* all the calendar accepts.
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
*/ */
fun publishedEventColors(calendarId: Long): List<EventColorOption> fun publishedEventColors(calendarId: Long): List<EventColorOption>
@@ -238,8 +229,10 @@ interface CalendarDataSource {
* and cancelled occurrences) to [targetCalendarId], returning the new * and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in * `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its * place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its exceptions, reminders and editable guests * `UID_2445` preserved), its reminders and editable guests copied, its
* replayed, the [original]→[updated] field edits applied, then the source * exceptions turned into EXDATEs (modified ones also into standalone events,
* as the new row has no `_sync_id` to attach them to), the
* [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure * deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched. * before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent]. * [allDayReminderTimeMinutes]: see [insertEvent].
@@ -323,8 +316,7 @@ class AndroidCalendarDataSource @Inject constructor(
private val registeredObservers = mutableSetOf<ContentObserver>() private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean = private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == context.hasPermission(Manifest.permission.READ_CALENDAR)
PackageManager.PERMISSION_GRANTED
/** /**
* Attach any not-yet-registered observers to the provider, but only once the * Attach any not-yet-registered observers to the provider, but only once the
@@ -358,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarProjection.COLUMNS, CalendarProjection.COLUMNS,
null, null, null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() )?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
} }
/** /**
@@ -375,7 +367,20 @@ class AndroidCalendarDataSource @Inject constructor(
) )
.build() .build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
/**
* Insert an owned, visible, syncing calendar on the Calendula local account;
* [extra] adds the columns that tell the kinds apart. [what] names the write
* in the failure — never the calendar name, which can reach a crash report.
*/
private fun insertLocalCalendar(
displayName: String,
color: Int,
what: String,
extra: ContentValues.() -> Unit,
): Long {
val name = displayName.trim() val name = displayName.trim()
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
@@ -391,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
) )
put(CalendarContract.Calendars.VISIBLE, 1) put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1) put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description) extra()
} }
val uri = resolver.insert(localCalendarsUri(), values) val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri) return ContentUris.parseId(uri)
} }
@@ -460,33 +463,15 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun canWriteCalendars(): Boolean = override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == context.hasPermission(Manifest.permission.WRITE_CALENDAR)
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
val name = displayName.trim() insertLocalCalendar(displayName, color, "create managed calendar") {
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar, // The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already // independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2. // holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type)) put(MANAGED_MARKER_COLUMN, markerFor(type))
} }
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query( override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI, CalendarContract.Calendars.CONTENT_URI,
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
// Seeded once, then never re-touched by sync — so a user who moves a // Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming // birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset). // occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows( insertReminderRows(
eventId, eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes), managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
) )
return eventId return eventId
} }
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) { override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return if (values.isEmpty()) return
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
} }
override fun applyManagedCalendarReminders( override fun applyManagedCalendarReminders(
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
minutes: List<Int>, minutes: List<Int>,
) { ) {
resolver.query( // Only our own mirror events, so a stray user event in this calendar
CalendarContract.Events.CONTENT_URI, // isn't all-day-re-encoded or stripped of its own reminders.
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), queryManagedEvents(calendarId).forEach { event ->
// Only our own mirror events, so a stray user event in this calendar // Managed events are all-day: DTSTART is a UTC midnight anchor.
// isn't all-day-re-encoded or stripped of its own reminders. reconcileReminders(
"${CalendarContract.Events.CALENDAR_ID} = ? AND " + event.eventId,
"${CalendarContract.Events.DELETED} = 0 AND " + managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
"${CalendarContract.Events.UID_2445} LIKE ?", )
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
} }
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a // Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a // cancelled exception for the one instance (#47). A NULL status is a
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
"${CalendarContract.Events.DELETED} = 0", "${CalendarContract.Events.DELETED} = 0",
null, null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
} }
override fun searchEvents(query: String): List<SearchCandidate> { override fun searchEvents(query: String): List<SearchCandidate> {
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
endMillis: Long, endMillis: Long,
soonestFirst: Boolean, soonestFirst: Boolean,
): Pair<Long, Long>? { ): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC" val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?", "${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
val attendees = queryAttendees(eventId) val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId) val reminders = queryReminders(eventId)
return resolver.query( return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
EventDetailProjection.COLUMNS, EventDetailProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> { override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList() val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query( return resolver.query(
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
null, null,
CalendarContract.Events.DTSTART + " ASC", CalendarContract.Events.DTSTART + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val reader = CursorColumnReader(c) val eventId = getLong(EventExportProjection.IDX_ID)
val eventId = reader.getLong(EventExportProjection.IDX_ID) val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID) toIcsEvent(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes }, reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId], calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes, allDayReminderTimeMinutes = allDayReminderTimeMinutes,
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
calendarId = calendarId, calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one // Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none. // only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
palette = colorPalette, palette = colorPalette,
) )
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
// (see [importedAllDayReminderDate]). // (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) { val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate( importedAllDayReminderDate(
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) startDate = utcDate(event.start.toEpochMilliseconds()),
.atZone(ZoneOffset.UTC).toLocalDate(),
recurrenceRule = event.recurrenceRule, recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone), today = LocalDate.now(zone),
) )
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
minutes minutes
} }
val reminder = ContentValues().apply { insertReminderRow(eventId, providerMinutes)
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
// The event row is already in. A reminder that won't attach costs
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
} }
return eventId return eventId
} }
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
// back to a stable synthesised UID at export time (deriveIcsUid). // back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues( val values = buildEventInsertValues(
form, form,
uid = "${UUID.randomUUID()}@calendula", uid = mintUid(),
times = form.toWriteTimes(ZoneId.systemDefault()), times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply { ) + eventColorColumns(form.colorKey, form.color)
// A null colour just leaves both columns unset (the event inherits // A new row has nothing to clear: unset columns inherit the
// its calendar's colour), so only the key/raw cases are written. // calendar's colour, so only the key/raw values are written.
when { .filterValues { it != null }
form.colorKey != null -> val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes) seedReminders(eventId, form, allDayReminderTimeMinutes)
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */ /** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) { private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes -> encodedMinutes.forEach { insertReminderRow(eventId, it) }
val reminder = ContentValues().apply { }
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes) /**
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) * Best effort: the event row is already in, so a reminder that won't attach
} * costs that one alarm, not the event. Throwing would report an event that
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { * was created as failed — and an import retry would then skip it as a
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") * duplicate.
} */
private fun insertReminderRow(
eventId: Long,
minutes: Int,
method: Int = CalendarContract.Reminders.METHOD_ALERT,
) {
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, method)
} }
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for event $eventId", e)
null
}
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
} }
/** /**
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
} }
// Untouched reminder sets are left alone so unrelated edits can't // Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the // disturb provider rows the form never knew about. The diff is on the
@@ -1067,16 +1009,17 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read event to move id=$eventId") ?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can // Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none. // recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
val newEventId = resolver.insert( val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(), buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId) )?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId") ?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
val detached = mutableListOf<Long>()
try { try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId) copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId)) insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId) copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
// Apply the user's field edits exactly as a same-calendar "all // Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values, // events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed // so the dirty diff against [original] writes only what changed
@@ -1086,6 +1029,7 @@ class AndroidCalendarDataSource @Inject constructor(
// Undo the partial copy so a failed move leaves nothing behind; the // Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point). // source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) } runCatching { deleteEvent(newEventId) }
detached.forEach(::rollBackDetached)
throw t throw t
} }
deleteEvent(eventId) deleteEvent(eventId)
@@ -1094,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** The master row of the event to move, as a verbatim-insert snapshot. */ /** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
MoveMasterProjection.COLUMNS, MoveMasterProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -1106,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION), duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE), rdate = r.getString(MoveMasterProjection.IDX_RDATE),
@@ -1115,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS) status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION), location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
), ),
@@ -1133,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(fromEventId.toString()), arrayOf(fromEventId.toString()),
null, null,
)?.use { c -> )?.use { c ->
while (c.moveToNext()) { while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit } ?: Unit
/** /**
@@ -1162,44 +1095,33 @@ class AndroidCalendarDataSource @Inject constructor(
} }
/** /**
* Replay every exception of the series [fromEventId] against the moved series * Carry every exception of the series [fromEventId] over to the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence * [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
* time still resolves: a cancelled occurrence is re-hidden with a cancelled * never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders * occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
* reconciled and editable guests copied). No-op for a non-recurring event. * event on [targetCalendarId], its id added to [detached] for rollback.
*/ */
private fun copyExceptions(fromEventId: Long, toEventId: Long) { private fun copyExceptions(
queryExceptionRows(fromEventId).forEach { ex -> fromEventId: Long,
if (ex.isCancelled) { toEventId: Long,
val values = ContentValues().apply { targetCalendarId: Long,
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis) allDayReminderTimeMinutes: Int,
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) detached: MutableList<Long>,
} ) {
resolver.insert( val exceptions = queryExceptionRows(fromEventId)
ContentUris.withAppendedId( if (exceptions.isEmpty()) return
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, val zone = TimeZone.currentSystemDefault()
), exceptions.filterNot { it.isCancelled }.forEach { ex ->
values, val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId") ?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
} else { val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val uri = resolver.insert( val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
ContentUris.withAppendedId( .copy(calendarId = targetCalendarId)
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, .toDetachedOccurrence()
), detached += insertEvent(form, allDayReminderTimeMinutes)
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
} }
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
} }
/** The series' exception rows (modified + cancelled), oldest occurrence first. */ /** The series' exception rows (modified + cancelled), oldest occurrence first. */
@@ -1210,26 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(seriesEventId.toString()), arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot( ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), exceptionEventId = getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED, isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
status = status, dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(), dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0, duration = getString(ExceptionProjection.IDX_DURATION),
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
) )
} }
} ?: emptyList() } ?: emptyList()
@@ -1266,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
val exceptionId = ContentUris.parseId(uri) val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key -> form.colorKey?.let { key ->
resolver.update( resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId), eventUri(exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) }, ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null, null, null,
) )
@@ -1320,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
// a fresh UID — the detached row is a separate event now, and sharing the // a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup. // parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes) val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still // Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the // exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match. // provider's expansion fails to match.
val updatedRows = try { try {
resolver.update( updateEventRow(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventId,
values.toContentValues(), null, null, row.exdateValues(listOf(beginMillis)),
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
) )
} catch (t: Throwable) { } catch (t: Throwable) {
rollBackDetached(detachedId) rollBackDetached(detachedId)
throw t throw t
} }
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId return detachedId
} }
@@ -1420,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
updated = updated, updated = updated,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) ?: return ) ?: return
val row = querySeriesRow(newEventId) val values = querySeriesRow(newEventId).timeColumns().apply {
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried) put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
} }
try { try {
val rows = resolver.update( updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { deleteEvent(newEventId) } runCatching { deleteEvent(newEventId) }
throw t throw t
@@ -1464,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) { private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" } requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply { val values = row.timeColumns().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put( put(
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)), rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
) )
} }
val rows = resolver.update( updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
} }
/** The series anchor: every time-related column of the Events row. */ /** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query( private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
arrayOf( arrayOf(
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
@@ -1534,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank() val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The time/recurrence set a series write carries together: the provider
* re-expands instances only from what the update itself holds.
*/
fun timeColumns(): ContentValues = ContentValues().apply {
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.RRULE, rrule)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
}
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate = exdate,
occurrences = occurrences,
dtStartMillis = dtStartMillis,
rrule = rrule,
rdate = rdate,
duration = duration,
timezone = timezone,
allDay = allDay,
)
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */ /** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis, beginMillis = beginMillis,
@@ -1559,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(eventId.toString(), minutes.toString()), arrayOf(eventId.toString(), minutes.toString()),
) )
} }
(target - existing).forEach { minutes -> insertReminderRows(eventId, (target - existing).toList())
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
} }
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */ private fun EventAttendee.key(): String = emailKey(email)
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */ /** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> { private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
@@ -1624,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
val relationship: Int, val relationship: Int,
val type: Int, val type: Int,
) { ) {
val key: String get() = email.trim().lowercase() val key: String get() = emailKey(email)
} }
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query( private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
@@ -1689,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
) )
} }
} }
target.filterNot { it.key() in editableKeys }.forEach { attendee -> insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
} }
private fun eventUri(eventId: Long): Uri =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
}
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
updateEventRow(eventId, values.toContentValues(), what)
private fun Map<String, Any?>.toContentValues(): ContentValues = private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv -> ContentValues().also { cv ->
forEach { (column, value) -> forEach { (column, value) ->
@@ -1716,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteEvent(eventId: Long) { override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete( val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
null, null, null, null,
) )
if (deleted == 0) throw WriteFailedException("delete event id=$eventId") if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
@@ -1738,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
// occurrence with it (#47 on a local calendar). EXDATE needs no link. // occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold // Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use. // yearly series, so this path is reached in normal use.
val values = buildOccurrenceExdateValues( updateEventRow(
existingExdate = row.exdate, eventId,
occurrenceMillis = beginMillis, row.exdateValues(listOf(beginMillis)),
dtStartMillis = row.dtStartMillis, "exdate occurrence event id=$eventId begin=$beginMillis",
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
) )
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return return
} }
// A cancelled exception row hides exactly this occurrence; the sync // A cancelled exception row hides exactly this occurrence; the sync
@@ -1804,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Attendees.EVENT_ID + " = ?", CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList() )?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query( private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI, CalendarContract.Reminders.CONTENT_URI,
@@ -1812,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Reminders.EVENT_ID + " = ?", CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList() )?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */ /** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList { private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll)) while (moveToNext()) add(mapper(this@mapAll))
} }
/** Iterate every row and map; drops nulls. */ /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList { private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */ /** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) { private fun markerFor(type: SpecialDateType): String = when (type) {
@@ -1840,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object { private companion object {
const val TAG = "CalendarDataSource" const val TAG = "CalendarDataSource"
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
fun emailKey(email: String): String = email.trim().lowercase()
/** /**
* Shared account for every app-created local calendar, so they group * Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager. * together (by account) in the filter sheet and calendar manager.
@@ -29,7 +29,7 @@ interface CalendarRepository {
/** /**
* The event-colour palette a calendar's account publishes; empty when it * The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]). * exposes none (see [CalendarDataSource.publishedEventColors]).
*/ */
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -1,10 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log import android.util.Log
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis = range.start.toEpochMillis(), beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(), endMillis = range.endInclusive.toEpochMillis(),
), ),
switchedOffCalendarIds = invisibleCalendarIds() + switchedOffCalendarIds = switchedOffCalendarIds(),
prefs.pendingDisabledCalendarIds.first(),
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
@@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor(
.filterNot { it.isVisibleInSystem } .filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id } .mapTo(mutableSetOf()) { it.id }
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
private suspend fun switchedOffCalendarIds(): Set<Long> =
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
private val calendarsLock = Mutex() private val calendarsLock = Mutex()
private var cachedGeneration = -1L private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null private var cachedPending: Set<Long>? = null
@@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) { override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) } withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
override suspend fun createLocalCalendar( override suspend fun createLocalCalendar(
displayName: String, displayName: String,
@@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write { write {
if (dataSource.canWriteCalendars()) { val canWrite = dataSource.canWriteCalendars()
ids.forEach { dataSource.setCalendarVisible(it, visible) } if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the // Nothing of ours is left waiting for the provider once the write
// write lands (and switching one back on retires its entry). // lands, and switching one back on retires its entry. With read-only
prefs.removePendingDisabledCalendarIds(ids) // permission a switch-off still works, app-side, and the reconciler
} else if (visible) { // flushes it if WRITE_CALENDAR ever arrives.
if (canWrite || visible) {
prefs.removePendingDisabledCalendarIds(ids) prefs.removePendingDisabledCalendarIds(ids)
} else { } else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids) prefs.addPendingDisabledCalendarIds(ids)
} }
} }
@@ -4,15 +4,15 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Settled ahead of the permission gate, so an update installed // Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade. // before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false) if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first() val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext if (pending.isEmpty() && noticeSettled) return@withContext
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Both decisions below are one-way, so leave them to the next run. // Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending)) settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) { if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext return@withContext
} }
val plan = calendarVisibilityPlan(calendars, pending) val plan = calendarVisibilityPlan(calendars, pending)
@@ -97,9 +97,6 @@ class CalendarVisibilityReconciler @Inject constructor(
false false
} }
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */ /** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/** /**
* Read-only view over a single row's columns by index. Lets the mappers work * Read-only view over a single row's columns by index. Lets the mappers work
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index) override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index) override fun isNull(index: Int): Boolean = cursor.isNull(index)
} }
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper" private const val TAG = "EventDetailMapper"
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would // — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix // surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper). // it. Clamp to a zero-length event instead (matching SearchMapper).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) { val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)) ?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own // Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value. // localized placeholder, and the edit form must prefill the true value.
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart // The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two // from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display. // cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY) val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR) val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the // and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date. // event's date is its UTC date.
val displayReminders = if (isAllDay) { val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(begin)
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
reminders.map { reminders.map {
it.copy( it.copy(
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
reminders reminders
} }
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail( return EventDetail(
instance = instance, instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION), description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees, attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE), rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders, reminders = displayReminders,
status = status, status = eventStatus(EventDetailProjection.IDX_STATUS),
// BUSY and DEFAULT are both 0, so a null column folds into the same // BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed. // default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -372,48 +372,11 @@ internal data class ExceptionRowSnapshot(
val exceptionEventId: Long, val exceptionEventId: Long,
val originalInstanceMillis: Long, val originalInstanceMillis: Long,
val isCancelled: Boolean, val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long, val dtStartMillis: Long,
val dtEndMillis: Long?, val dtEndMillis: Long?,
val duration: String?, val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
) )
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/** /**
* Column values for a *cancelled*-occurrence exception row ("delete only this * Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
@@ -479,9 +442,24 @@ internal fun buildOccurrenceExdateValues(
duration: String?, duration: String?,
timezone: String?, timezone: String?,
allDay: Int, allDay: Int,
): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
)
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
internal fun buildOccurrencesExdateValues(
existingExdate: String?,
occurrences: List<Long>,
dtStartMillis: Long,
rrule: String?,
rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = mapOf( ): Map<String, Any?> = mapOf(
CalendarContract.Events.EXDATE to CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone), mergedExdate(exdate, millis, allDay != 0, timezone)
},
CalendarContract.Events.DTSTART to dtStartMillis, CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule, CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate, CalendarContract.Events.RDATE to rdate,
@@ -631,7 +609,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of * optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half). * a zoned one ([exdateListZone] reads that half).
*/ */
private fun exdateStamps(exdate: String?): List<String> = exdate internal fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters() ?.withoutExdateParameters()
?.split(',') ?.split(',')
?.map { it.trim() } ?.map { it.trim() }
@@ -901,7 +879,7 @@ internal fun buildImportedEventValues(
*/ */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0) val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S" return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
} }
private fun EventStatus.toProviderStatus(): Int = when (this) { private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
/** /**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent] * Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0 val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it // Unclamped: the writer renders DTEND as stored.
// so the writer can render DTEND. A missing/blank both means a zero-length event. val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent( return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart), uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION), location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION), description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) { reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(dtStart)
reminderMinutes.map { reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
} }
} else { } else {
reminderMinutes reminderMinutes
}, },
status = status, status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName, calendarName = calendarName,
) )
@@ -112,11 +97,7 @@ internal fun exportExDates(
): List<String> { ): List<String> {
if (exdate.isNullOrBlank()) return emptyList() if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone) val zone = exdateListZone(exdate, eventTimezone)
return exdate return exdateStamps(exdate)
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp -> .map { stamp ->
if (isAllDay) { if (isAllDay) {
stamp.substringBefore('T') stamp.substringBefore('T')
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
// string, so it belongs to the UI layer where it can be localised (#321). // string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty() val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance( return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID), instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -1,7 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection { internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID, CalendarContract.Calendars._ID,
@@ -251,40 +260,26 @@ internal object MoveMasterProjection {
/** /**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to * The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified * carry them over to a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters * occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't replayed. * `DELETED = 0` so provider tombstones aren't carried over.
*/ */
internal object ExceptionProjection { internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID, CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME, CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS, CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND, CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION, CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
) )
const val IDX_ID = 0 const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1 const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2 const val IDX_STATUS = 2
const val IDX_TITLE = 3 const val IDX_DTSTART = 3
const val IDX_ALL_DAY = 4 const val IDX_DTEND = 4
const val IDX_DTSTART = 5 const val IDX_DURATION = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
} }
internal object AttendeeProjection { internal object AttendeeProjection {
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/** /**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search. // long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART) val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = when { val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) .coerceAtLeast(dtStart)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty() val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID) val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance( return EventInstance(
@@ -2,16 +2,15 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import de.jeanlucmakiola.floret.android.hasPermission
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */ /** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean = fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == hasPermission(Manifest.permission.READ_CONTACTS)
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */ /** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) { internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
@@ -24,8 +24,6 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -87,8 +85,4 @@ object DataProvideModule {
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
} }
@@ -1,7 +0,0 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.data.prefs package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import de.jeanlucmakiola.floret.prefs.Pref
import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.floret.prefs.PrefStore
import androidx.datastore.preferences.core.longPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,18 +24,13 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class CalendarPrefs @Inject constructor( class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>, store: DataStore<Preferences>,
) { ) {
private val prefs = PrefStore(store)
// Both id sets are deduped: the store is shared with SettingsPrefs, so any val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
// unrelated write would otherwise re-emit an identical set.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun setHiddenCalendarIds(ids: Set<Long>) { suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
/** /**
* Switch-offs the provider does not know about yet, because writing * Switch-offs the provider does not know about yet, because writing
@@ -48,24 +41,18 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by * second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may. * entry as soon as the app may write, and nothing adds to it while it may.
*/ */
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) = suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
editPendingDisabled { it + ids } prefs.update(DISABLED_IDS) { it + ids }
}
/** /**
* Drop [ids] from the pending set, one at a time as the reconciler flushes * Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed. * them, so a run that fails part-way never re-applies what already landed.
*/ */
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) = suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
editPendingDisabled { it - ids.toSet() } prefs.update(DISABLED_IDS) { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
}
} }
/** /**
@@ -73,39 +60,28 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar * owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed. * permission), false once it has been shown or was never needed.
*/ */
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs -> val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
prefs[VISIBILITY_NOTICE_KEY]
}
suspend fun setVisibilityNoticePending(pending: Boolean) { suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
}
/** /**
* The calendar the user last created an event in; preselected in the * The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created. * event form. Null until the first event is created.
*/ */
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs -> val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
prefs[LAST_USED_CALENDAR_KEY]
}
suspend fun setLastUsedCalendarId(id: Long) { suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
companion object { private companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") val DISABLED_IDS = idSetPref("disabled_calendar_ids")
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending") val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
} }
} }
private fun String?.parseIds(): Set<Long> = orEmpty() /** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
.split(',') private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
.mapNotNull { it.trim().toLongOrNull() } { stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
.toSet() { ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}
@@ -2,10 +2,8 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.floret.prefs.PrefStore
import androidx.datastore.preferences.core.longPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableLongPref
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,17 +18,16 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class ReminderStatePrefs @Inject constructor( class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>, store: DataStore<Preferences>,
) { ) {
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */ /** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first() suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun setLastScanMillis(millis: Long) { suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object { private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis") val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
} }
} }
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,8 @@ import android.content.Intent
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -44,27 +41,17 @@ class ReminderActionReceiver : BroadcastReceiver() {
// Cancel now so the notification doesn't linger until the alarm; // Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work. // the snooze delay read is the only async work.
notifier.cancel(alert) notifier.cancel(alert)
val pendingResult = goAsync() goAsync {
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { val minutes = settingsPrefs.snoozeMinutes.first()
try { val triggerAt = System.currentTimeMillis() + minutes * 60_000L
val minutes = settingsPrefs.snoozeMinutes.first() scheduler.schedule(alert, triggerAt)
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
} }
} }
ACTION_SHOW -> { ACTION_SHOW -> {
val pendingResult = goAsync() goAsync {
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
try { notifier.post(alert)
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
} }
} }
} }
@@ -4,20 +4,12 @@ import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 31–32.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/** /**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm * Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and * exists at a time, for the earliest reminder ahead; every firing re-scans and
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
fun scheduleScan(triggerAtMillis: Long) { fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT) alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
} }
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */ /** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource { ) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> { override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = instancesUri(fromMillis, toMillis)
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75). // `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means // The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event. // "normal", so a bare `!= CANCELED` would drop every ordinary event.
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -19,6 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek import java.time.DayOfWeek
@@ -48,8 +47,7 @@ class ReminderNotifier @Inject constructor(
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean { fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
@@ -63,13 +61,9 @@ class ReminderNotifier @Inject constructor(
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() || calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false calendarDataSource.isCalendarVisible(calendarId) == false
/** /** Post [alert], unless its calendar is switched off. */
* Post [alert], unless its calendar is switched off. Returns whether the suspend fun post(alert: ReminderAlert) {
* notification was put up, which the snooze re-show path uses to tell a if (isSilenced(alert.calendarId)) return
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
@@ -122,8 +116,6 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here. // POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
} }
// Handled either way — a retry hits the same revoked permission.
return true
} }
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
@@ -2,19 +2,18 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis) alarms.scheduleScan(schedule.nextAlarmMillis)
} }
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission( private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/** /**
* Re-scan when the provider changes, so a saved or deleted event re-arms the * Re-scan when the provider changes, so a saved or deleted event re-arms the
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -29,14 +26,7 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
// exported and the broadcasts it takes are protected, so any other // exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to. // action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync() goAsync { scanner.scan() }
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
} }
companion object { companion object {
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert), ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
if (canScheduleExact(alarmManager)) { alarmManager.setWakeup(triggerAtMillis, pendingIntent)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 31–32); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
} }
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 31–32.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
} }
@@ -1,18 +1,17 @@
package de.jeanlucmakiola.calendula.data.time package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -34,21 +33,8 @@ class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : TodaySource { ) : TodaySource {
override val today: Flow<LocalDate> = callbackFlow { override val today: Flow<LocalDate> = context
val receiver = object : BroadcastReceiver() { .broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.transformLatest { .transformLatest {
while (true) { while (true) {
emit(currentDate()) emit(currentDate())
@@ -61,3 +47,21 @@ class AndroidTodaySource @Inject constructor(
private const val MINUTE_MILLIS = 60_000L private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* Today as a hot flow seeded with [initial]. [onRollover] runs with the previous
* and the new date each time the day changes while collected, so a screen left
* open over midnight can move off yesterday (#373).
*/
fun Flow<LocalDate>.stateFollowingDays(
scope: CoroutineScope,
initial: LocalDate,
onRollover: (old: LocalDate, new: LocalDate) -> Unit,
): StateFlow<LocalDate> {
var last = initial
return onEach { date ->
val old = last
last = date
if (old != date) onRollover(old, date)
}.stateWhileSubscribed(scope, initial)
}
@@ -1,17 +1,12 @@
package de.jeanlucmakiola.calendula.data.time package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import javax.inject.Inject import javax.inject.Inject
@@ -33,17 +28,7 @@ class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ZoneSource { ) : ZoneSource {
override val zone: Flow<TimeZone> = callbackFlow { override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
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() } .map { current() }
.distinctUntilChanged() .distinctUntilChanged()
@@ -148,23 +148,21 @@ enum class EventFormProblem {
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an // All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone. // anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) { val pinned = if (instance.isAllDay) {
null null
} else { } else {
eventTimezone eventTimezone
?.takeIf { it != zone.id } ?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or // An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open. // shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } ?.let { id -> zoneOrNull(id)?.let { id to it } }
} }
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis) val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis) val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) { val (start, end) = if (instance.isAllDay) {
val startDate = begin.toLocalDateTime(TimeZone.UTC).date allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
begin.toFormTime(formZone) to finish.toFormTime(formZone) begin.toFormTime(formZone) to finish.toFormTime(formZone)
} }
@@ -180,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the // The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its // swatch either way; a null colour means the event inherits its
// calendar's colour. // calendar's colour.
@@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
rowEnd = instance.end, rowEnd = instance.end,
) )
/**
* Form times for an all-day span stored as UTC midnights with an exclusive
* end: the last covered day (never before [start]), with placeholder
* wall-clock times kept in case the user switches the event to timed.
*/
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
}
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/** /**
* Whether this form's times mean something else in [target] — a timed event * 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 * pinned to another zone. False when there's nothing to disambiguate: an
@@ -243,8 +254,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
*/ */
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean { fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false return resolvedZone(target).id != target.id
return pinned.id != target.id
} }
/** /**
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -101,19 +101,13 @@ object EventSearch {
val today = todayStart.toLocalDateTime(zone).date val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) } val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today } val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 } // What is left reads forwards; what is behind us reads backwards from
.thenComparator { a, b -> // today, nearest first.
// What is left reads forwards; what is behind us reads backwards val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
// from today, nearest first. past.sortedByDescending { it.event.dayStart(zone) }
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
return SearchResults( return SearchResults(
months = groupByMonth(dated, zone, todayStart), months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit }, inDescriptions = inDescriptions.map { it.hit },
@@ -126,33 +120,18 @@ object EventSearch {
zone: TimeZone, zone: TimeZone,
todayStart: Instant, todayStart: Instant,
): List<SearchMonth> { ): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
return dated val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
.groupBy { monthKey(it.event.spanFirstDay(zone)) } val (behind, ahead) = groups.keys.partition { it < current }
.entries return (ahead.sorted() + behind.sortedDescending()).map { month ->
.sortedWith( SearchMonth(
compareBy<Map.Entry<Int, List<Matched>>> { year = month.year,
when { monthNumber = month.month.number,
it.key == currentKey -> 0 hits = groups.getValue(month).map { it.hit },
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
) )
.map { (key, entries) -> }
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
} }
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match( private fun match(
candidate: SearchCandidate, candidate: SearchCandidate,
tokens: List<String>, tokens: List<String>,
@@ -17,12 +17,15 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/** /**
* The zone this form's wall-clock times mean, matching what the data layer * The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone]. * resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/ */
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone timezone?.let(::zoneOrNull) ?: deviceZone
/** /**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats * This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
@@ -1,8 +1,5 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@@ -35,17 +32,11 @@ fun buildInsertEventForm(
now: Instant, now: Instant,
): EventForm { ): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date ?: now.toLocalDateTime(zone).date
val endDate = endMillis val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } allDayFormTimes(startDate, endExclusive)
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now) val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt } val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
@@ -62,7 +53,7 @@ fun buildInsertEventForm(
description = description.orEmpty(), description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include. // some callers include.
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
) )
} }
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
rrule: String, rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(), zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? { ): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';') val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null return null
} }
val freq = when (parts["FREQ"]?.uppercase()) { val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
"DAILY" -> RecurrenceFreq.Daily ?: return null
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1 val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal // BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
when (val e = end) { when (val e = end) {
RecurrenceEnd.Never -> Unit RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> { is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) append(";UNTIL=")
.toInstant(zone) append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
} }
is RecurrenceEnd.Count -> append(";COUNT=${e.times}") is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
} }
} }
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
internal class RrulePart(val raw: String) {
private val eq = raw.indexOf('=')
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
}
/**
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
* with a malformed one (reject, repair or skip) is the caller's call.
*/
internal fun rruleParts(rule: String): List<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf( internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO", DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU", DayOfWeek.TUESDAY to "TU",
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val key = part.substringBefore('=').trim().uppercase() val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT" key == "UNTIL" || key == "COUNT"
} }
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";") return (kept + untilPart).joinToString(";")
} }
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so * forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked. * a [toRRule]-rendered value round-trips to the day the user picked.
*/ */
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
val date = LocalDate( val date = parseBasicDate(raw) ?: return null
raw.substring(0, 4).toInt(), if (raw.length < 15 || raw[8] != 'T') return date
raw.substring(4, 6).toInt(), return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
raw.substring(6, 8).toInt(), }
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } val parts = rruleParts(rrule)
if (parts.isEmpty()) return null if (parts.isEmpty()) return null
var freq = false
var weekly = false var weekly = false
var byDay = false
val rebuilt = parts.map { part -> val rebuilt = parts.map { part ->
val eq = part.indexOf('=') val value = part.value.trim()
if (eq <= 0) return null when (part.key) {
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> { "FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true) weekly = value.equals("WEEKLY", ignoreCase = true)
part part.raw
} }
"BYDAY" -> { "BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
} }
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
else -> return null else -> return null
} }
} }
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null if (!freq || (byDay && !weekly)) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";") return prefix + rebuilt.joinToString(";")
} }
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440 private const val MINUTES_PER_DAY = 1_440
/** /**
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
dtEnd: IcsDateTime?, dtEnd: IcsDateTime?,
duration: String?, duration: String?,
): Instant { ): Instant {
val explicit = dtEnd?.instant ?: duration?.let { val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1) return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
} }
/** /**
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/ */
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> { private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList() if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time val startTime = start.instant.toLocalDateTime(startZone).time
return lines return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } } .flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) -> .mapNotNull { (line, token) ->
when { when {
token.isEmpty() -> null token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
else -> parseBasicDate(token) else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } ?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
} }
} }
.distinct() .distinct()
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when { val zone = when {
token.endsWith("Z") -> TimeZone.UTC token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } else -> line.params["TZID"]?.let(::zoneOrNull)
?: startZone ?: startZone
} }
return ldt.toInstant(zone) return ldt.toInstant(zone)
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC") if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"] val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } } val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone) if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone) val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId) return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -421,16 +415,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) = fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true) name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/** /**
* A single-valued `CATEGORIES`, which is what the Fossify family writes * A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and * for the source calendar's name. A multi-item list is a tag list and
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.takeIf { it.isNotEmpty() && !it.equals("null", true) } .takeIf { it.isNotEmpty() && !it.equals("null", true) }
} }
/** Index of the matching `END:<component>` at/after [from], or list end. */
/** /**
* Where an unterminated component's body has to stop: the next top-level * Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary. * nested `VALARM` is part of the body and is not a boundary.
*/ */
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int { fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
var i = from (from until end).firstOrNull { i ->
while (i < end) {
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
return i } ?: end
}
i++
}
return end
}
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { /** Index of the matching `END:<component>` at/after [from], or list end. */
var i = from fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
while (i < lines.size) { (from until lines.size).firstOrNull { i ->
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && line.name == "END" && line != null && line.name == "END" && line.value.trim().equals(component, true)
line.value.trim().equals(component, true) } ?: lines.size
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) { fun mapIcsStatus(value: String): EventStatus =
"TENTATIVE" -> EventStatus.Tentative EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
"CANCELLED" -> EventStatus.Cancelled ?: EventStatus.Confirmed
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
} }
} }
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf( private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
) )
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",") return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
} }
for (part in rule.split(';')) { // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // being dropped, so it falls through and marks the rule repaired.
// being dropped, so it falls through and marks the rule repaired. for (part in rruleParts(raw.orEmpty().trim())) {
if (part.isBlank()) continue val key = part.key?.trim().orEmpty()
val key = part.substringBefore('=', "").trim().uppercase() val value = part.value.trim()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when { val kept = when {
key.isEmpty() || value.isEmpty() -> null key.isEmpty() || value.isEmpty() -> null
key == "FREQ" -> key == "FREQ" ->
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) { private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT") add("BEGIN:VEVENT")
add("UID:${event.uid}") add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}") add("DTSTAMP:${dtStamp.toUtcBasic()}")
add("SUMMARY:${escapeText(event.summary)}") add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event) appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() } event.recurrenceRule?.takeIf { it.isNotBlank() }
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendTimes(event: IcsEvent) = when { private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> { event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}") add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}") add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
} }
// Recurring: anchor to wall-clock in the event's own zone. // Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> { event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() val zone = zoneOrNull(event.zoneId)
if (zone != null) { if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
} else { } else {
// Unknown zone id → fall back to plain UTC instants. // Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
else -> { else -> {
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
// A lead time of 0 fires at start (PT0M); anything positive is "before". // A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String = fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M" if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
} }
} }
@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import de.jeanlucmakiola.calendula.domain.normalizedRrule
import de.jeanlucmakiola.calendula.domain.toFormTime import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -23,10 +21,7 @@ import kotlinx.datetime.toLocalDateTime
*/ */
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
} else { } else {
this.start.toFormTime(zone) to this.end.toFormTime(zone) this.start.toFormTime(zone) to this.end.toFormTime(zone)
} }
@@ -40,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = semanticReminderMinutes(),
availability = availability, availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = recurrenceRule.normalizedRrule(),
) )
} }
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState import androidx.compose.animation.core.SeekableTransitionState
@@ -28,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -39,6 +41,7 @@ import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
@@ -99,7 +102,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -116,29 +118,18 @@ import kotlin.time.Instant
* view remains. So back from a widget-opened screen returns to that widget's * view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse. * view, then home; and back through pill switches walks the views in reverse.
* *
* [requestedDetailKey] is an externally requested occurrence (a tapped * [requests] carries what notifications, widgets and other apps asked to
* reminder notification routed through MainActivity): it opens the detail * open. A tapped reminder's occurrence opens the detail overlay exactly like an
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed] * event tap; each request is cleared once acted on so a later recomposition
* so a later recomposition can't re-open it. (A widget event tap instead arrives * can't re-open it. (A widget event tap instead arrives as
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * [WidgetNavRequest.OpenEvent], which also roots the back stack in the widget's
* widget's view.) * view.)
*/ */
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarHost( fun CalendarHost(
requests: ExternalRequests,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -163,7 +154,7 @@ fun CalendarHost(
val view = viewStack.last() val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day // The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there. // anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
// A focus still on yesterday follows the date over, so the next view opened // A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put. // doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value val today = viewModel.today.collectAsStateWithLifecycle().value
@@ -194,27 +185,17 @@ fun CalendarHost(
// it overlays whichever calendar view is active. We forward the tapped // it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable // occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start. // long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when // Like every overlay below, it draws its last key through the slide-out
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0] // ([rememberHeld]) rather than a placeholder.
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) } var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) } val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
val onOpenDetail: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
// A tapped reminder notification asks for a specific occurrence. // A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) { LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) { if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey detailKey = requestedDetailKey
onDetailKeyConsumed() requests.detailKey = null
} }
} }
@@ -238,31 +219,17 @@ fun CalendarHost(
// calendar list rather than by preferences. Reached from both. // calendar list rather than by preferences. Reached from both.
var showBackup by rememberSaveable { mutableStateOf(false) } var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen: // Event form (v1.2 create), opened on a date, a tapped slot or a drafted block.
// [heldCreateIso] keeps the prefill date alive through the slide-out. var createRequest by rememberSaveable(stateSaver = createRequestSaver) {
// [createStartMinutes] is the tapped slot's start (minutes from midnight) mutableStateOf<CreateRequest?>(null)
// when the form is opened from a day/week grid tap; null from the FAB.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's start and end (#153) as epoch millis. Instants rather than
// minutes into a day, so the form opens at the times drawn even across a DST
// change (#387).
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
heldCreateDraftMillis = draftMillis
createDraftMillis = draftMillis
} }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) } fun openCreate(dateIso: String, startMinutes: Int? = null, draftMillis: LongArray? = null) {
createRequest = CreateRequest(dateIso, startMinutes, draftMillis)
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date.toString(), startMinutes) }
val onCreateDraft: (Instant, Instant) -> Unit = { start, end -> val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds())) openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
} }
// Edit form (v1.3) — reuses the detail screen's occurrence key; for // Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -272,7 +239,6 @@ fun CalendarHost(
// falling back to the auto-refreshing calendar is the only honest // falling back to the auto-refreshing calendar is the only honest
// destination. // destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) } var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens // An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk // the prefilled create form (one event → [importForm]) or its own bulk
@@ -306,7 +272,7 @@ fun CalendarHost(
importForm = null importForm = null
if (importFromRequest) { if (importFromRequest) {
importFromRequest = false importFromRequest = false
onExternalFormClosed() requests.onExternalFormClosed()
} }
} }
} }
@@ -325,26 +291,28 @@ fun CalendarHost(
// what tells it a *re-import of the same file* is new work and not the run // what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result. // it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) } var importSession by rememberSaveable { mutableStateOf(0) }
val requestedImportUri = requests.importUri
LaunchedEffect(requestedImportUri) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++ importSession++
onImportConsumed() requests.importUri = null
} }
} }
// An external ACTION_INSERT launch (another app/widget creating an event, // An external ACTION_INSERT launch (another app/widget creating an event,
// issue #30) arrives already prefilled — open it in the same create form the // issue #30) arrives already prefilled — open it in the same create form the
// single-event .ics path uses. [importForm] is the topmost overlay, so it // single-event .ics path uses. [importForm] is the topmost overlay, so it
// reveals on top of whatever was open without extra dismissal. // reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) { val requestedInsert = requests.insert
if (requestedInsertForm != null) { LaunchedEffect(requestedInsert) {
if (requestedInsert != null) {
if (importForm == null) { if (importForm == null) {
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true) openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else { } else {
pendingInsert = requestedInsertForm to requestedInsertSource pendingInsert = requestedInsert.form to requestedInsert.source
} }
onInsertConsumed() requests.insert = null
} }
} }
@@ -367,21 +335,22 @@ fun CalendarHost(
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or // An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering // widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so // overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as // without this it would open hidden underneath them. A saved edit just
// a detail-screen "Edit" tap; a saved edit just returns to the calendar. // returns to the calendar.
val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) { LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) { if (requestedEditKey != null) {
dismissCoveringOverlays() dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey editKey = requestedEditKey
onEditKeyConsumed() requests.editKey = null
} }
} }
// A home-screen widget launch asks to open a date (→ day view), open an // A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // the detail request. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default. // view so backing out returns there (then home), not to the default.
val widgetNavRequest = requests.nav
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) { if (widgetNavRequest != null) {
navigationVersion++ navigationVersion++
@@ -392,23 +361,21 @@ fun CalendarHost(
// Drill the day view in over the widget's view: drop any overlay // Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
focus.navigateTo(LocalDate.parse(req.dateIso)) focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed() requests.nav = null
} }
is WidgetNavRequest.OpenEvent -> { is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence // Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder). // detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
viewStack = viewBaseStack(defaultView, req.source) viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis) detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key requests.nav = null
detailKey = key
onWidgetNavConsumed()
} }
is WidgetNavRequest.OpenView -> { is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date // A widget header tap: land on a top-level view with no date
@@ -416,10 +383,10 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the // the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
focus.navigateTo(localToday()) focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed() requests.nav = null
} }
is WidgetNavRequest.Create -> { is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut / // External "new event" entries (QS tile / launcher shortcut /
@@ -427,15 +394,8 @@ fun CalendarHost(
// sits below Settings/calendars in the Box, so without this it // sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them. // would open hidden underneath them.
dismissCoveringOverlays() dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now() openCreate(req.dateIso ?: currentDate().toString())
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() requests.nav = null
heldCreateIso = iso
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
onWidgetNavConsumed()
} }
null -> {} null -> {}
} }
@@ -448,15 +408,7 @@ fun CalendarHost(
val eventActions: EventActionsViewModel = hiltViewModel() val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle() val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle() val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event -> val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldEditKey = key
editKey = key
}
// A tap opens the edit form instead of the detail screen when the user chose // 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 // 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. // tap time, so a tap before they've loaded still routes right.
@@ -494,15 +446,10 @@ fun CalendarHost(
) )
} }
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay // Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view, // owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app. // so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
editKey != null || showSettings || showCalendars || showBackup || editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null importUri != null || importForm != null
// The view switch runs on a seekable transition, so a back gesture can scrub // The view switch runs on a seekable transition, so a back gesture can scrub
@@ -696,11 +643,7 @@ fun CalendarHost(
// Search overlay — below detail/edit in the Box so a tapped result's // 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. // detail screen draws on top, and closing it returns to the results.
AnimatedVisibility( SlideOverlay(visible = showSearch) {
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SearchScreen( SearchScreen(
onBack = { showSearch = false }, onBack = { showSearch = false },
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -708,23 +651,15 @@ fun CalendarHost(
) )
} }
// Prefer the live key; fall back to the held one only while sliding out. val shownDetailKey = rememberHeld(detailKey)
val activeKey = detailKey ?: heldKey SlideOverlay(visible = detailKey != null) {
AnimatedVisibility( shownDetailKey?.let { key ->
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
activeKey?.let { key ->
EventDetailScreen( EventDetailScreen(
eventId = key[0], eventId = key[0],
beginMillis = key[1], beginMillis = key[1],
endMillis = key[2], endMillis = key[2],
onBack = { detailKey = null }, onBack = { detailKey = null },
onEdit = { onEdit = { editKey = key },
heldEditKey = key
editKey = key
},
onDuplicate = { form -> onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a // Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an // fresh event, so it applies the default reminder like an
@@ -738,32 +673,26 @@ fun CalendarHost(
} }
// Event form (v1.2) — full-screen destination, slides over the calendar. // Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility( val shownCreate = rememberHeld(createRequest)
visible = createDateIso != null, SlideOverlay(visible = createRequest != null) {
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), shownCreate?.let { create ->
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
initialDateIso = iso, initialDateIso = create.dateIso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes, initialStartMinutes = create.startMinutes,
initialDraft = createDraftMillis ?: heldCreateDraftMillis, initialDraft = create.draftMillis,
onClose = { createDateIso = null }, onClose = { createRequest = null },
onSaved = { createDateIso = null }, onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = createDateIso != null, visible = createRequest != null,
viewModel = hiltViewModel(key = "create"), viewModel = hiltViewModel(key = "create"),
) )
} }
} }
// Edit form (v1.3) — slides over the detail screen. // Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility( val shownEditKey = rememberHeld(editKey)
visible = editKey != null, SlideOverlay(visible = editKey != null) {
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), shownEditKey?.let { key ->
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen( EventEditScreen(
initialDateIso = null, initialDateIso = null,
editKey = key, editKey = key,
@@ -780,10 +709,7 @@ fun CalendarHost(
// below to show why, so open it. // below to show why, so open it.
onLoadFailed = { onLoadFailed = {
editKey = null editKey = null
if (detailKey == null) { if (detailKey == null) detailKey = key
heldKey = key
detailKey = key
}
}, },
visible = editKey != null, visible = editKey != null,
viewModel = hiltViewModel(key = "edit"), viewModel = hiltViewModel(key = "edit"),
@@ -792,11 +718,7 @@ fun CalendarHost(
} }
// Settings (M4) — full-screen destination, slides over the calendar. // Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility( SlideOverlay(visible = showSettings) {
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
@@ -854,11 +776,7 @@ fun CalendarHost(
// Declared last so it covers every overlay that can open it: Settings, // Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76). // both event forms, and the .ics import picker (#76).
AnimatedVisibility( SlideOverlay(visible = showCalendars) {
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
CalendarsScreen( CalendarsScreen(
onBack = { showCalendars = false }, onBack = { showCalendars = false },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
@@ -867,11 +785,7 @@ fun CalendarHost(
} }
// Backup & restore — over the manager, since the manager links into it. // Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility( SlideOverlay(visible = showBackup) {
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
// Settings can open this screen without the manager underneath, so // Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the // its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings // manager rather than just pop — popping alone lands on Settings
@@ -910,5 +824,56 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
) )
private fun localToday(): LocalDate = /**
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date * An overlay's slide-in from the end edge, leaving from the predictive-back
* preview (#184): shrinking on from where the gesture let go instead of
* snapping back and sliding off.
*/
@Composable
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
exit = predictiveBackExit(),
content = content,
)
}
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
private fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
/**
* A create-form open: its date, plus the tapped slot's start (minutes from
* midnight) from a day/week grid tap, or a drafted block's start and end (#153)
* as epoch millis. Instants rather than minutes into a day, so the form opens at
* the times drawn even across a DST change (#387).
*/
private class CreateRequest(
val dateIso: String,
val startMinutes: Int?,
val draftMillis: LongArray?,
)
private val createRequestSaver = Saver<CreateRequest?, List<Any?>>(
save = { request -> request?.let { listOf(it.dateIso, it.startMinutes, it.draftMillis) } },
restore = { CreateRequest(it[0] as String, it[1] as Int?, it[2] as LongArray?) },
)
@@ -8,11 +8,10 @@ import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import javax.inject.Inject import javax.inject.Inject
@@ -29,40 +28,35 @@ class CalendarHostViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */ /** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateIn( val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = currentDate(), initialValue = currentDate(),
) )
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn( val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */ /** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle } .map { it.cycle }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, initialValue = IMPLEMENTED_VIEWS,
) )
/** Order of the views in the navigation drawer (#24); every view always shown. */ /** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, initialValue = IMPLEMENTED_VIEWS,
) )
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */ /** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
@@ -74,9 +68,8 @@ class CalendarHostViewModel @Inject constructor(
* switched off to be rid of. * switched off to be rid of.
*/ */
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.ui
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
/**
* What a notification, widget, shortcut or another app asked the running app to
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
* new one into the running activity); CalendarHost acts on a request and clears
* it, so a later recomposition can't act on it twice.
*
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
* activity can hand control back to the app that asked.
*/
@Stable
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
var detailKey by mutableStateOf<LongArray?>(null)
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
var nav by mutableStateOf<WidgetNavRequest?>(null)
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
var importUri by mutableStateOf<Uri?>(null)
/**
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
* shared text (#306), opened in the create form for review.
*/
var insert by mutableStateOf<InsertRequest?>(null)
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
}
/** A prefilled create form from an external launch, with the source it came from. */
data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -20,11 +18,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
@@ -43,27 +41,12 @@ private sealed interface RootTarget {
@Composable @Composable
fun RootScreen( fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(context.canReadCalendar())
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
} }
// A launch scan already covers the app coming up with the permission, so // A launch scan already covers the app coming up with the permission, so
@@ -74,9 +57,7 @@ fun RootScreen(
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event -> val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = ContextCompat.checkSelfPermission( hasPermission = context.canReadCalendar()
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the // Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated // worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it. // on the permission so users without the opt-in never enqueue it.
@@ -146,19 +127,8 @@ fun RootScreen(
when (shown) { when (shown) {
RootTarget.Loading -> Unit RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost( RootTarget.App -> CalendarHost(
requests = requests,
modifier = modifier, modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
onExternalFormClosed = onExternalFormClosed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
// Both halves of a transition read their position out of the live // Both halves of a transition read their position out of the live
// plan; the one the outgoing step was drawn with is already gone. // plan; the one the outgoing step was drawn with is already gone.
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/** /**
* How far ahead the agenda (screen or widget) shows events, starting at today. * How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart DAYS_PER_WEEK - sinceWeekStart
} }
AgendaRange.ThisMonth -> { AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() val daysInMonth = anchor.yearMonth.numberOfDays
daysInMonth - anchor.day + 1 daysInMonth - anchor.day + 1
} }
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
locale: Locale, locale: Locale,
monthAsSpan: Boolean = false, monthAsSpan: Boolean = false,
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaStart = start.toJavaLocalDate()
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) val javaEnd = end.toJavaLocalDate()
val dayMonth = localizedDateFormatter(locale, "dMMM") val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy") val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when { return when {
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -9,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
@@ -30,23 +34,25 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant
import java.util.Locale import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's // The agenda's row vocabulary, split out of AgendaScreen so the month view's
@@ -177,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource( is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts, R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale), formatTimeOfDay(label.start, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Ends -> stringResource( is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends, R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale), formatTimeOfDay(label.end, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
formatTime(label.end, zone, is24Hour, locale) formatTimeOfDay(label.end, zone, is24Hour, locale)
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = date.toJavaLocalDate()
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java) return localizedDateFormatter(locale, "EEEdMMMy").format(java)
} }
/**
* The agenda's day-by-day list body: a sticky header per day, its events or a
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
* and the month view's split pane so both lists read the same.
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.agendaDays(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
@StringRes emptyText: Int,
onEmptyClick: (LocalDate) -> Unit,
dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(emptyText),
onClick = { onEmptyClick(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimmed(event, day.date),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -10,9 +10,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
@@ -66,11 +62,9 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -379,7 +373,6 @@ internal fun AgendaContent(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
@@ -444,39 +437,17 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
days.forEach { day -> agendaDays(
stickyHeader(key = "header-${day.date}") { days = days,
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) today = today,
} zone = zone,
if (day.events.isEmpty()) { // An anchored, event-less today (#35) — "nothing left today".
// An anchored, event-less today (#35) — "nothing left today". emptyText = R.string.agenda_no_more_today,
item(key = "placeholder-${day.date}") { onEmptyClick = onOpenDay,
AgendaEmptyDayRow( dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
text = stringResource(R.string.agenda_no_more_today), onOpenDay = onOpenDay,
onClick = { onOpenDay(day.date) }, onEventClick = onEventClick,
) )
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
} }
} }
@@ -1,19 +1,22 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,17 +27,13 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -66,9 +65,8 @@ class AgendaViewModel @Inject constructor(
* the screen combines it with a per-minute "now" to fade or drop past rows. * the screen combines it with a per-minute "now" to fade or drop past rows.
*/ */
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW, initialValue = PastEventDisplay.SHOW,
) )
@@ -79,9 +77,8 @@ class AgendaViewModel @Inject constructor(
* alongside the data state like [pastEventDisplay] rather than re-querying. * alongside the data state like [pastEventDisplay] rather than re-querying.
*/ */
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true, initialValue = true,
) )
@@ -101,21 +98,14 @@ class AgendaViewModel @Inject constructor(
var lastTopDate: LocalDate? = null var lastTopDate: LocalDate? = null
private set private set
private var lastToday = todayDate
/** /**
* Today, kept current across midnight (#373). An agenda still starting on * Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put. * yesterday moves on to the new day; one jumped elsewhere stays put.
*/ */
private val today: StateFlow<LocalDate> = todaySource.today private val today: StateFlow<LocalDate> =
.onEach { date -> todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
val old = lastToday if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
lastToday = date
if (old != date && _window.value.anchor == old) {
_window.value = _window.value.copy(anchor = date)
}
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory // A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the // (not persisted), so it survives view switches and rotation within the
@@ -156,9 +146,8 @@ class AgendaViewModel @Inject constructor(
} }
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading, initialValue = AgendaUiState.Loading,
) )
@@ -245,8 +234,5 @@ class AgendaViewModel @Inject constructor(
internal const val PAST_CHUNK_DAYS = 30 internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */ /** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> { internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
val from = anchor.atStartOfDayIn(zone) dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.calendars package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -54,13 +53,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import java.time.LocalDate import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out // SAF mime filter for the restore picker. Source apps hand `.ics` files out
@@ -222,8 +225,8 @@ private fun BackupContent(
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore) val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) { if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header)) GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary) GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_restore_action), title = stringResource(R.string.calendars_restore_action),
position = Position.Alone, position = Position.Alone,
@@ -233,7 +236,7 @@ private fun BackupContent(
return return
} }
HintText(stringResource(R.string.calendars_backup_hint)) GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_backup_action), title = stringResource(R.string.calendars_backup_action),
position = Position.Top, position = Position.Top,
@@ -272,7 +275,7 @@ private fun BackupContent(
position = Position.Bottom, position = Position.Bottom,
onClick = onEditInterval, onClick = onEditInterval,
) )
HintText(backupStatusText(autoBackup.status)) GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
} }
} }
@@ -309,12 +312,13 @@ private fun ExportCalendarPicker(
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.calendars_export_title), title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true,
) { ) {
HintText(stringResource(R.string.calendars_export_hint)) GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected val isSelected = calendar.id in selected
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = calendar.description, summary = calendar.description,
position = positionOf(index, calendars.size), position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -376,9 +380,7 @@ private fun backupIntervalLabel(minutes: Long): String {
@Composable @Composable
private fun backupStatusText(status: BackupStatus): String { private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString( val relative = relativeTimeAgo(status.lastRun)
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) { return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative) stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else { } else {
@@ -12,27 +12,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -40,20 +34,14 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -84,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -93,12 +82,14 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -224,7 +215,7 @@ private fun CalendarsList(
visible = visible, visible = visible,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint)) GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
// Local (device-only) calendars — one collapsible group. The header's // Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once; // "+" adds a calendar; the switch enables/disables them all at once;
@@ -245,24 +236,13 @@ private fun CalendarsList(
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) }, onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) { ) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
} else { } else {
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == local.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
},
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
) )
} }
@@ -291,8 +271,8 @@ private fun CalendarsList(
// Synced calendars — read-only, grouped by account. Each account is a // Synced calendars — read-only, grouped by account. Each account is a
// collapsible group whose header opens the source app (icon) and toggles // collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch). // all of its calendars at once (switch).
SectionHeader(stringResource(R.string.calendars_synced_header)) GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
synced synced
.groupByAccount() .groupByAccount()
.forEach { group -> .forEach { group ->
@@ -333,27 +313,10 @@ private fun CalendarsList(
// bottom, dimmed and switchless. // bottom, dimmed and switchless.
val ordered = cals.orderedForManager() val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar -> ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == ordered.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
) )
} }
} }
@@ -692,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and * `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description. * stops its reminders. Carries its own content description.
*/ */
/**
* One calendar in a group: dimmed while switched off or not syncing, with the
* visibility switch only where it can change anything.
*/
@Composable @Composable
private fun EnableSwitch( private fun CalendarManagerRow(
calendarName: String, calendar: CalendarSource,
enabled: Boolean, last: Boolean,
onToggle: (Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
) { ) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName) val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
Switch( GroupedRow(
checked = enabled, title = calendar.displayName.orUnnamed(),
onCheckedChange = onToggle, summary = calendarRowSummary(calendar),
modifier = Modifier.semantics { contentDescription = label }, position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarVisibilitySwitch(
calendarName = calendar.displayName.orUnnamed(),
enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
}
} else {
null
},
onClick = onClick,
) )
} }
@@ -841,33 +824,6 @@ private fun CalendarGroupMenu(
} }
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/** /**
* Pick the app to open for managing a synced calendar's account. The account's * Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission * own authenticator package (resolved from [AccountManager], no permission
@@ -9,16 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -52,9 +51,8 @@ class CalendarsViewModel @Inject constructor(
repository.calendars() repository.calendars()
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(), initialValue = emptyList(),
) )
@@ -68,9 +66,8 @@ class CalendarsViewModel @Inject constructor(
.map { backupUiState(it) } .map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading, initialValue = BackupUiState.Loading,
) )
@@ -84,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
AutoBackupUiState(enabled, interval, folder, status) AutoBackupUiState(enabled, interval, folder, status)
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(), initialValue = AutoBackupUiState(),
) )
@@ -103,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet() if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(), initialValue = emptySet(),
) )
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
*/ */
@Composable @Composable
fun accountGroupTitle(group: CalendarAccountGroup): String { fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } val label = group.label.orUnnamed()
return if (!group.ambiguous) { return if (!group.ambiguous) {
label label
} else { } else {
@@ -77,6 +77,8 @@ class EventSelection(
val event: EventInstance, val event: EventInstance,
val dismiss: () -> Unit, val dismiss: () -> Unit,
val create: (() -> Unit)? = null, val create: (() -> Unit)? = null,
/** The event at the times an ongoing move or resize would give it, else null. */
val preview: () -> EventInstance? = { null },
) )
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ /** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@@ -126,10 +128,12 @@ fun PublishSelection(
compact: Boolean, compact: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null, onCreate: (() -> Unit)? = null,
preview: () -> EventInstance? = { null },
) { ) {
val slot = LocalChromeSlot.current ?: return val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss) val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate) val currentCreate by rememberUpdatedState(onCreate)
val currentPreview by rememberUpdatedState(preview)
val drafted = onCreate != null val drafted = onCreate != null
val selection = remember(event, drafted) { val selection = remember(event, drafted) {
event?.let { event?.let {
@@ -137,6 +141,7 @@ fun PublishSelection(
event = it, event = it,
dismiss = { currentDismiss() }, dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null, create = if (drafted) ({ currentCreate?.invoke() }) else null,
preview = { currentPreview() },
) )
} }
} }
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/** /**
* The app's single "which calendar" selection list, shared by the event editor * The app's single "which calendar" selection list, shared by the event editor
@@ -114,17 +113,11 @@ private fun CalendarPickerGroup(
leading = leading, leading = leading,
) )
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected, checked = calendar.id == selectedId,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(calendar.id) }, onClick = { onSelect(calendar.id) },
) )
} }
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.R
/** /**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in * The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() } with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
} }
} }
/** A calendar or account name, or the "unnamed" label when it is blank. */
@Composable
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
@Composable
fun CalendarVisibilitySwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -23,7 +22,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
@@ -32,6 +30,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Clock
@@ -73,10 +72,13 @@ suspend fun CalendarRepository.deleteEvent(
/** How a [tryDeleteEvent] ended. */ /** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed } enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may read the calendar at all. */
fun Context.canReadCalendar(): Boolean =
hasPermission(Manifest.permission.READ_CALENDAR)
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */ /** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean = fun Context.canWriteCalendar(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == hasPermission(Manifest.permission.WRITE_CALENDAR)
PackageManager.PERMISSION_GRANTED
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */ /** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent( suspend fun CalendarRepository.tryDeleteEvent(
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException 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.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -15,16 +14,16 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -50,9 +49,8 @@ class EventActionsViewModel @Inject constructor(
.map { list -> list.associateBy { it.id } } .map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) } .catch { emit(emptyMap()) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(), initialValue = emptyMap(),
) )
@@ -212,11 +212,21 @@ private fun rememberPickUpSurface(
} }
} }
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */ /**
* A lift on pickup, a tick every time the drop target snaps to a new slot, and a
* confirm each time [drops] counts a real drop.
*/
@Composable @Composable
fun DragSnapHaptics(slot: Any?) { fun DragSnapHaptics(slot: Any?, drops: Int) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val previous = remember { arrayOfNulls<Any>(1) } val previous = remember { arrayOfNulls<Any>(1) }
val dropsSeen = remember { intArrayOf(drops) }
LaunchedEffect(drops) {
if (drops != dropsSeen[0]) {
dropsSeen[0] = drops
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
}
}
LaunchedEffect(slot) { LaunchedEffect(slot) {
val had = previous[0] val had = previous[0]
previous[0] = slot previous[0] = slot
@@ -9,7 +9,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -45,7 +45,7 @@ class PageStateCache<K, S>(
return entries.getOrPut(key) { return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job]) val job = SupervisorJob(scope.coroutineContext[Job])
Entry( Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial), state = load(key).stateWhileSubscribed(scope + job, initial),
job = job, job = job,
) )
}.state }.state
@@ -1,14 +1,8 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -22,17 +16,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
@@ -90,7 +83,7 @@ fun ReminderDefaultPicker(
} }
val inherits = current is ReminderOverride.Inherit val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None val isNone = current is ReminderOverride.None
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty() val selectedMinutes = current.minutesOrEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps // Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap, // its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it. // which would strand a hand-entered value with no way to re-check it.
@@ -121,24 +114,14 @@ fun ReminderDefaultPicker(
GroupedRow( GroupedRow(
title = stringResource(R.string.reminder_use_default), title = stringResource(R.string.reminder_use_default),
position = Position.Top, position = Position.Top,
selected = inherits, checked = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.Inherit) }, onClick = { apply(ReminderOverride.Inherit) },
) )
} }
GroupedRow( GroupedRow(
title = stringResource(R.string.reminder_none), title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone, position = if (allowInherit) Position.Bottom else Position.Alone,
selected = isNone, checked = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.None) }, onClick = { apply(ReminderOverride.None) },
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -154,17 +137,11 @@ fun ReminderDefaultPicker(
onClick = { toggle(minute) }, onClick = { toggle(minute) },
) )
} }
// When expanded, the Custom row connects downward into the editor card CustomExpandRow(
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom), title = stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), position = positionOf(rows.size, rowCount),
onClick = { customExpanded = !customExpanded }, expanded = customExpanded,
) onExpandedChange = { customExpanded = it },
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomReminderEditor( CustomReminderEditor(
amountText = amountText, amountText = amountText,
@@ -212,19 +189,6 @@ private fun CustomReminderEditor(
) )
} }
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/** /**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30 * options (today / this week / this month) and the rolling windows (next 7 / 30
@@ -265,17 +229,11 @@ fun AgendaRangePicker(
} }
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow( GroupedRow(
title = agendaRangeLabel(option), title = agendaRangeLabel(option),
summary = windowSummary(option), summary = windowSummary(option),
position = position, position = position,
selected = isSelected, checked = option == selected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(option) onSelect(option)
onDismiss() onDismiss()
@@ -284,7 +242,7 @@ fun AgendaRangePicker(
} }
FullScreenPicker(title = title, onDismiss = onDismiss) { FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description) GroupedHint(description, verticalPadding = 8.dp)
// Calendar-aligned windows. // Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option -> calendarAligned.forEachIndexed { index, option ->
@@ -297,7 +255,7 @@ fun AgendaRangePicker(
rolling.forEachIndexed { index, option -> rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount)) rangeRow(option, positionOf(index, rollingRowCount))
} }
GroupedRow( CustomExpandRow(
title = if (customSelected) { title = if (customSelected) {
agendaRangeLabel(selected) agendaRangeLabel(selected)
} else { } else {
@@ -306,19 +264,10 @@ fun AgendaRangePicker(
// An unset custom window has no day count yet, so the row stays // An unset custom window has no day count yet, so the row stays
// single-line until it does. // single-line until it does.
summary = if (customSelected) windowSummary(selected) else null, summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), position = positionOf(rolling.size, rollingRowCount),
selected = customSelected, expanded = customExpanded,
trailing = if (customSelected) { onExpandedChange = { customExpanded = it },
{ SelectedCheck() } checked = customSelected,
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomDaysEditor( CustomDaysEditor(
amountText = amountText, amountText = amountText,
@@ -396,17 +345,12 @@ fun DurationPicker(
} }
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) { FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description) if (description != null) GroupedHint(description, verticalPadding = 8.dp)
if (inheritLabel != null) { if (inheritLabel != null) {
GroupedRow( GroupedRow(
title = inheritLabel, title = inheritLabel,
position = Position.Alone, position = Position.Alone,
selected = inherits, checked = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(null) onSelect(null)
onDismiss() onDismiss()
@@ -415,39 +359,22 @@ fun DurationPicker(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
presets.forEachIndexed { index, minute -> presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow( GroupedRow(
title = label(minute), title = label(minute),
position = positionOf(index, rowCount), position = positionOf(index, rowCount),
selected = isSelected, checked = minute == selected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(minute) onSelect(minute)
onDismiss() onDismiss()
}, },
) )
} }
// The Custom row connects downward into the editor card when expanded, so CustomExpandRow(
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom), title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), position = positionOf(presets.size, rowCount),
selected = custom != null, expanded = customExpanded,
trailing = if (custom != null) { onExpandedChange = { customExpanded = it },
{ SelectedCheck() } checked = custom != null,
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomDurationEditor( CustomDurationEditor(
amountText = amountText, amountText = amountText,
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.rruleParts
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
@@ -32,10 +34,7 @@ import java.util.Locale
*/ */
@Composable @Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
.atOffset(ZoneOffset.UTC) .atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone) .atZoneSameInstant(zone)
.toLocalDate() .toLocalDate()
}.recoverCatching { }.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
}.getOrNull()
} }
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -23,22 +22,21 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant import kotlin.time.Instant
@@ -189,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() } .map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) } .catch { emit(emptySet<Long>()) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(), initialValue = emptySet(),
) )
@@ -11,6 +11,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -30,10 +32,13 @@ import kotlin.time.toJavaInstant
@Composable @Composable
fun SelectionTopBar(selection: EventSelection) { fun SelectionTopBar(selection: EventSelection) {
val event = selection.event val event = selection.event
// Follows a move or resize, so the times stay readable under the finger.
val preview by remember(selection) { derivedStateOf { selection.preview() } }
val shown = preview ?: event
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val whenText = remember(event.start, event.end, use24Hour, locale) { val whenText = remember(shown.start, shown.end, use24Hour, locale) {
formatSelectionWhen(event, use24Hour, locale) formatSelectionWhen(shown, use24Hour, locale)
} }
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
@@ -9,6 +9,7 @@ import kotlin.time.Instant
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toLocalDateTime
/** /**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"), * The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
@@ -37,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59)) LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale)) .format(timeOfDayFormatter(is24Hour, locale))
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
val time = instant.toLocalDateTime(zone).time
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
}
/** /**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value * Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight * 1440 renders as "24:00" in 24h (its established reading) and as midnight
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -118,19 +116,14 @@ fun TimeZonePickerDialog(
title = stringResource(R.string.event_edit_timezone_device), title = stringResource(R.string.event_edit_timezone_device),
summary = deviceSummary, summary = deviceSummary,
position = Position.Alone, position = Position.Alone,
selected = selected == null, checked = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) }, leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) }, onClick = { choose(null) },
) )
} }
if (recentZones.isNotEmpty()) { if (recentZones.isNotEmpty()) {
item(key = "recent_header") { item(key = "recent_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_recent)) GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
} }
itemsIndexed( itemsIndexed(
items = recentZones, items = recentZones,
@@ -145,7 +138,7 @@ fun TimeZonePickerDialog(
} }
} }
item(key = "all_header") { item(key = "all_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_all)) GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
} }
} }
@@ -234,23 +227,6 @@ private fun SearchField(
} }
} }
/** A small primary-coloured group label, matching the settings screens. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable @Composable
private fun ZoneRow( private fun ZoneRow(
zone: TimeZoneOption, zone: TimeZoneOption,
@@ -262,12 +238,7 @@ private fun ZoneRow(
title = zone.label, title = zone.label,
summary = zoneDescriptor(zone), summary = zoneDescriptor(zone),
position = position, position = position,
selected = selected, checked = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick, onClick = onClick,
) )
} }
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -19,8 +18,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -389,17 +390,20 @@ internal fun resizedSpan(
} }
if (wrongWay) return startMin to endMin if (wrongWay) return startMin to endMin
} }
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = snapToSlot(edgeMin)
return when (edge) { return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES) ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
} }
} }
/** [minutes] rounded to the nearest [DRAG_SNAP_MINUTES] slot. */
internal fun snapToSlot(minutes: Float): Int = (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
/** [minutes] snapped, kept at [held] until three quarters of a step away. */ /** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int { internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES return snapToSlot(minutes)
} }
/** How far past its column's side the finger has to go before a resize changes day. */ /** How far past its column's side the finger has to go before a resize changes day. */
@@ -467,6 +471,10 @@ class TimelineDragController {
var settling: TimelineDrag? by mutableStateOf(null) var settling: TimelineDrag? by mutableStateOf(null)
private set private set
/** How many real drops this controller has handed back. */
var drops by mutableIntStateOf(0)
private set
/** /**
* Which block is lifted, and whether anything is. Separate snapshot state * Which block is lifted, and whether anything is. Separate snapshot state
* from [drag], which changes every frame, so blocks that only need "am I the * from [drag], which changes every frame, so blocks that only need "am I the
@@ -761,6 +769,12 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null 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). // 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 if (landed.edgeOnly && landed.event.isDraft) return null
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // 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 // slot: the event belongs to a day this timeline has no column for, so
@@ -770,7 +784,8 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) settledEnd = end
drops++
} }
/** /**
@@ -801,7 +816,7 @@ class TimelineDragController {
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0) 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)) val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 } resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it } ?.let { column(it) }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) - resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null originSlot = null
@@ -809,6 +824,25 @@ class TimelineDragController {
originSlot = drag?.slot originSlot = drag?.slot
} }
/** The slot the gesture would land on, changing once per snap rather than per frame. */
private val landingSlot by derivedStateOf { drag?.slot }
/**
* The dragged event at the times letting go now would give it, worked out as
* [finish] and [finishResize] do. Null while it is still on its own slot (#141).
*/
fun preview(zone: TimeZone): EventInstance? {
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
val event = source?.event ?: return null
val edge = resizeEdge
if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
return event.copy(start = start, end = start + dragDuration)
}
/** /**
* End a resize, handing back the event's new span — null when it never * 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. * resolved or came back to the length it started at. Settles as [finish] does.
@@ -820,12 +854,7 @@ class TimelineDragController {
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settle(landed, end = drop.end)
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 return drop
} }
@@ -841,14 +870,13 @@ class TimelineDragController {
val days = geometry.days val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val column = stickyColumn( val held = stickyColumn(
position = (pointer.x - origin.x) / columnPx, position = (pointer.x - origin.x) / columnPx,
held = resizeColumn, held = resizeColumn,
lastIndex = days.lastIndex, lastIndex = days.lastIndex,
) )
resizeColumn = column resizeColumn = held
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
} }
@@ -861,8 +889,6 @@ class TimelineDragController {
resizeSlot = slot resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay) val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view. // Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt() val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
@@ -875,20 +901,7 @@ class TimelineDragController {
eventStartMin = start, eventStartMin = start,
eventSpanMin = end - start, eventSpanMin = end - start,
titleLines = titleLines, titleLines = titleLines,
pieces = slices.map { slice -> pieces = pieces(slices, startIndex, origin),
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(), edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) { markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY) start.mod(MINUTES_PER_DAY)
@@ -946,10 +959,9 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = snapToSlot(rawMinutes)
val zone = dragZone ?: return val zone = dragZone ?: return
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val dayIndex = 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 target = days[dayIndex]
val keep = dragKept val keep = dragKept
// Measured where it would land: the drop keeps the real duration, which in // Measured where it would land: the drop keeps the real duration, which in
@@ -977,28 +989,34 @@ class TimelineDragController {
titleLines = titleLines, titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it // Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected. // doesn't has no piece to draw. The write is unaffected.
pieces = slices pieces = pieces(slices, dayIndex, origin),
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = onGrid.isEmpty(), edgeOnly = onGrid.isEmpty(),
) )
} }
/** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */
private fun pieces(slices: List<DragSlice>, dayIndex: Int, origin: Offset): List<TimelineDragPiece> {
val columnPx = geometry.columnWidthPx
return slices.map { slice ->
val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + column(dayIndex + slice.dayOffset) * columnPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
}
}
/** The on-screen column of a day index, and back: right-to-left lays the days out mirrored. */
private fun column(index: Int): Int = if (geometry.isRtl) geometry.days.lastIndex - index else index
/** /**
* Hold the scroll for the whole drag and nudge it once per frame while the * Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, since a loop of * finger sits near an edge. One `scroll` call, since a loop of
@@ -1160,7 +1178,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which // 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. // changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot) DragSnapHaptics(controller.drag?.slot, controller.drops)
// Only once the finger has moved: a long press held near an edge must not // 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. // scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) { LaunchedEffect(controller.isDragging && controller.pointerMoved) {
@@ -16,8 +16,6 @@ data class TimelineHours(
val startHour: Int = DEFAULT_START_HOUR, val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR, val endHour: Int = DEFAULT_END_HOUR,
) { ) {
val span: Int get() = endHour - startHour
companion object { companion object {
const val DEFAULT_START_HOUR = 7 const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22 const val DEFAULT_END_HOUR = 22
@@ -1,145 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/**
* The viewport a view preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling every preview from one fixed
* height keeps them comparable — each shows the same slice of screen.
*/
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
/**
* Renders [content] at a full phone viewport and shrinks the finished layout
* into a box [height] tall, for the settings and onboarding choosers. Callers
* pass the *real* view composables, so a preview cannot drift from what it
* depicts.
*
* The [layout] below ignores the incoming constraints so the view measures at a
* phone viewport, and a [graphicsLayer] scale shrinks the result.
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
* overflows its constraints, leaving only the bottom-right corner in the clip.
*/
@Composable
internal fun ScaledViewPreview(
height: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// view's own clickables see them, and give screen readers one label
// instead of six weeks of day cells.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes
.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { }
// Measure at a full phone viewport but report the scaled size, so the
// node occupies exactly what it draws.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
},
) {
Column(Modifier.fillMaxSize()) { content() }
}
}
/**
* The stage every preview picker sets its preview on, cross-fading as [selected]
* changes. A preview draws the app's own surface, so a tonal plate gives it the
* edge it would otherwise lack on a chooser screen; the plate takes the width
* and outer radius of the grouped rows underneath so the two read as one stack.
*
* Only vertical spacing is the caller's to pass — the horizontal inset is the
* rows' own, and the alignment is the point.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset)
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the plate rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
private val FRAME_INSET = 8.dp
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -58,7 +56,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -87,11 +84,13 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.InlineStyle import de.jeanlucmakiola.calendula.domain.InlineStyle
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.RichText import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -104,7 +103,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -304,7 +302,7 @@ private fun EventDetailContent(
val detail = state.detail val detail = state.detail
val instance = detail.instance val instance = detail.instance
val dark = LocalFloretDarkTheme.current val dark = LocalFloretDarkTheme.current
val locale = currentDetailLocale() val locale = currentLocale()
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current) val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column( Column(
@@ -385,7 +383,7 @@ private fun EventDetailContent(
// "When" — date/all-day plus the time range. // "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale) val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) { IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium) Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) { if (whenSecondary != null) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
@@ -410,7 +408,7 @@ private fun EventDetailContent(
} }
foreignZone?.let { zoneOption -> foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Public, icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone), iconContentDescription = stringResource(R.string.event_detail_timezone),
) { ) {
@@ -423,7 +421,7 @@ private fun EventDetailContent(
// — which a cross-zone event easily does — carries the whole span // — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be // 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. // read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() val originalTime = zoneOrNull(zoneOption.id)
?.let { rememberWhen(instance, it, locale) } ?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary } ?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
@@ -442,13 +440,13 @@ private fun EventDetailContent(
// Calendar — icon tinted in the calendar colour conveys identity, so no // Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed. // separate colour dot is needed.
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.CalendarMonth, icon = Icons.Default.CalendarMonth,
iconTint = accent, iconTint = accent,
iconContentDescription = stringResource(R.string.event_detail_calendar), iconContentDescription = stringResource(R.string.event_detail_calendar),
) { ) {
Text( Text(
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) } text = state.calendarName?.orUnnamed()
?: stringResource(R.string.event_detail_calendar_unknown), ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@@ -460,7 +458,7 @@ private fun EventDetailContent(
val context = LocalContext.current val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location) val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Place, icon = Icons.Default.Place,
iconContentDescription = locationLabel, iconContentDescription = locationLabel,
) { ) {
@@ -487,7 +485,7 @@ private fun EventDetailContent(
state.description?.let { description -> state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description) val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.AutoMirrored.Filled.Notes, icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = descriptionLabel, iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer // The gesture sits on the card so the icon and padding answer
@@ -506,7 +504,7 @@ private fun EventDetailContent(
// each attendee with their role and reply. // each attendee with their role and reply.
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees -> detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.People, icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees), iconContentDescription = stringResource(R.string.event_detail_attendees),
) { ) {
@@ -528,7 +526,7 @@ private fun EventDetailContent(
// Reminders (conditional) — list each lead time before the event. // Reminders (conditional) — list each lead time before the event.
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders -> detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Notifications, icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders), iconContentDescription = stringResource(R.string.event_detail_reminders),
) { ) {
@@ -537,7 +535,7 @@ private fun EventDetailContent(
.sortedBy { it.minutes } .sortedBy { it.minutes }
.forEach { reminder -> .forEach { reminder ->
Text( Text(
text = reminderLeadText(reminder), text = reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) )
@@ -548,7 +546,7 @@ private fun EventDetailContent(
// Recurrence (conditional) — humanised from the RRULE. // Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule -> detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Repeat, icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence), iconContentDescription = stringResource(R.string.event_detail_recurrence),
) { ) {
@@ -561,36 +559,6 @@ private fun EventDetailContent(
} }
} }
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
@Composable @Composable
private fun AttendeeRow(attendee: Attendee) { private fun AttendeeRow(attendee: Attendee) {
Row( Row(
@@ -714,11 +682,6 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) {
// --- helpers ------------------------------------------------------------- // --- helpers -------------------------------------------------------------
// Observable locale read (shared helper) — avoids NonObservableLocale /
// LocalContextConfigurationRead lint by going through LocalConfiguration.
@Composable
private fun currentDetailLocale(): Locale = currentLocale()
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) { private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
AttendeeStatus.Accepted -> R.string.event_attendee_accepted AttendeeStatus.Accepted -> R.string.event_attendee_accepted
AttendeeStatus.Declined -> R.string.event_attendee_declined AttendeeStatus.Declined -> R.string.event_attendee_declined
@@ -738,10 +701,6 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
else -> null else -> null
} }
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/** /**
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and * [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* pinned to a zone different from the device's — the cases where showing it * pinned to a zone different from the device's — the cases where showing it
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException 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.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -17,11 +16,12 @@ import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -76,9 +75,8 @@ class EventDetailViewModel @Inject constructor(
} }
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading, initialValue = EventDetailUiState.Loading,
) )
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException 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.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -23,7 +22,6 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.nextFullHour import de.jeanlucmakiola.calendula.domain.nextFullHour
import de.jeanlucmakiola.calendula.domain.toFormTime import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -43,6 +41,8 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_O
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -339,9 +339,8 @@ class EventEditViewModel @Inject constructor(
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported, allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
) )
} }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
@@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.sourceAppName import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -43,6 +45,7 @@ fun CalendarFilterList(
is FilterUiState.Failure -> FilterMessage(s.reason, modifier) is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> FilterList( is FilterUiState.Success -> FilterList(
groups = s.groups, groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible, onSetVisible = viewModel::setVisible,
modifier = modifier, modifier = modifier,
) )
@@ -56,23 +59,15 @@ fun CalendarFilterList(
*/ */
@Composable @Composable
private fun FilterList( private fun FilterList(
groups: List<AccountGroup>, groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group -> groups.forEach { group ->
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
Text( Text(
text = if (group.ambiguous) { text = accountGroupTitle(group),
stringResource(
R.string.calendars_account_from_source,
account,
sourceAppName(group.accountType),
)
} else {
account
},
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -83,18 +78,19 @@ private fun FilterList(
), ),
) )
group.calendars.forEachIndexed { index, cal -> group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
GroupedRow( GroupedRow(
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = cal.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size), position = positionOf(index, group.calendars.size),
minHeight = 56.dp, minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) }, leading = { CalendarColorChip(cal.color) },
trailing = { trailing = {
Checkbox( Checkbox(
checked = cal.visible, checked = visible,
onCheckedChange = { onSetVisible(cal.id, it) }, onCheckedChange = { onSetVisible(cal.id, it) },
) )
}, },
onClick = { onSetVisible(cal.id, !cal.visible) }, onClick = { onSetVisible(cal.id, !visible) },
) )
} }
} }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.filter package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
/** /**
* State for the calendar-filter sheet (M3). The user toggles per-calendar * State for the calendar-filter sheet (M3). The user toggles per-calendar
@@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
sealed interface FilterUiState { sealed interface FilterUiState {
data object Loading : FilterUiState data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState data class Failure(val reason: FailureReason) : FilterUiState
data class Success(val groups: List<AccountGroup>) : FilterUiState /** A calendar shows when its id is *not* in [hidden]. */
data class Success(
val groups: List<CalendarAccountGroup>,
val hidden: Set<Long>,
) : FilterUiState
} }
/**
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
*
* [accountType] and [ambiguous] carry what the header needs to tell two
* same-named accounts from different apps apart (#77); the label itself is
* built in the UI layer, which is where the source app's name can be looked up.
*/
data class AccountGroup(
val account: String,
val accountType: String,
val ambiguous: Boolean,
val calendars: List<CalendarRow>,
)
data class CalendarRow(
val id: Long,
val displayName: String,
val color: Int,
val visible: Boolean,
)
@@ -4,20 +4,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -40,14 +38,13 @@ class FilterViewModel @Inject constructor(
if (failure != null) { if (failure != null) {
FilterUiState.Failure(failure) FilterUiState.Failure(failure)
} else { } else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) FilterUiState.Success(enabled.groupByAccount(), hidden)
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FilterUiState.Loading, initialValue = FilterUiState.Loading,
) )
@@ -61,28 +58,3 @@ class FilterViewModel @Inject constructor(
} }
} }
/**
* Group calendars under their owning account — by name *and* type, so two
* accounts that merely share a name stay apart (#77) — preserving the
* provider's order within each group and ordering groups by first appearance.
* A calendar is "visible" when its id is *not* in [hidden].
*/
internal fun groupCalendarsForFilter(
calendars: List<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
calendars.groupByAccount().map { group ->
AccountGroup(
account = group.label,
accountType = group.accountType,
ambiguous = group.ambiguous,
calendars = group.calendars.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}
@@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
/** /**
@@ -23,8 +22,7 @@ internal fun monthChipTime(
locale: Locale, locale: Locale,
): String? { ): String? {
if (event.isAllDay || continuesLeft) return null if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time return formatTimeOfDay(event.start, zone, is24Hour, locale)
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
} }
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ /** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -133,6 +134,10 @@ class MonthDragController {
var settling: MonthChipDrag? by mutableStateOf(null) var settling: MonthChipDrag? by mutableStateOf(null)
private set private set
/** How many real drops this controller has handed back. */
var drops by mutableIntStateOf(0)
private set
/** /**
* Which chip is lifted, and whether anything is. Separate snapshot state from * Which chip is lifted, and whether anything is. Separate snapshot state from
* [drag], which changes every frame, so the chips and rows that only need * [drag], which changes every frame, so the chips and rows that only need
@@ -291,6 +296,7 @@ class MonthDragController {
settledGhost = true settledGhost = true
undoable = settling undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0 settledAbandonedMark = abandonedTicks?.value ?: 0
drops++
return MonthChipDrop(landed.event, landed.grabDate, target) return MonthChipDrop(landed.event, landed.grabDate, target)
} }
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -21,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent 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.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
@@ -67,7 +68,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -75,11 +75,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -167,8 +163,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.isPastOn import de.jeanlucmakiola.calendula.ui.common.isPastOn
@@ -198,10 +192,13 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
private val LocalTodayPulse = staticCompositionLocalOf { 0 } private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -350,7 +347,7 @@ fun MonthScreen(
if (dense) { if (dense) {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY) .plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month) midweek.yearMonth
} else { } else {
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
} }
@@ -399,7 +396,7 @@ fun MonthScreen(
} }
} }
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) val isOnCurrentMonth = titleMonth == today.yearMonth
var splitExpanded by rememberSaveable(viewStyle) { var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged) mutableStateOf(viewStyle == MonthViewStyle.Paged)
} }
@@ -428,7 +425,7 @@ fun MonthScreen(
val jumpToToday: () -> Unit = { val jumpToToday: () -> Unit = {
todayPulse++ todayPulse++
if (scrolling) { if (scrolling) {
val targetMonth = YearMonth(today.year, today.month) val targetMonth = today.yearMonth
pendingScrollMonth = targetMonth pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId val requestId = ++scrollRequestId
viewModel.goToToday() viewModel.goToToday()
@@ -446,13 +443,13 @@ fun MonthScreen(
} }
} else { } else {
viewModel.goToToday() viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month)) jumpToMonth(today.yearMonth)
} }
} }
// Drawer jump-to-date. // Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) { if (scrolling) {
val targetMonth = YearMonth(target.year, target.month) val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId val requestId = ++scrollRequestId
viewModel.goToDate(target) viewModel.goToDate(target)
@@ -470,7 +467,7 @@ fun MonthScreen(
} }
} else { } else {
viewModel.goToDate(target) viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month)) jumpToMonth(target.yearMonth)
} }
} }
@@ -534,7 +531,7 @@ fun MonthScreen(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
) )
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) { } else {
SplitMonthContent( SplitMonthContent(
state = state, state = state,
selected = selectedDate, selected = selectedDate,
@@ -554,23 +551,12 @@ fun MonthScreen(
// still settling would land and take the tap back. // still settling would land and take the tap back.
onSelectDay = { date -> onSelectDay = { date ->
viewModel.selectDate(date) viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month)) jumpToMonth(date.yearMonth)
}, },
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = { onCreateEvent(it, null) }, onCreateEvent = { onCreateEvent(it, null) },
) )
} else {
MonthContent(
state = state,
pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} }
} }
} }
@@ -596,7 +582,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val moveInFlight = moveInFlight() val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes // Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to. // every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate) DragSnapHaptics(controller.drag?.targetDate, controller.drops)
// Hold the landed chip until the write is done — including the time a // Hold the landed chip until the write is done — including the time a
// recurring drop's scope dialog is up — and then, unlike the timeline, carry // recurring drop's scope dialog is up — and then, unlike the timeline, carry
// it to its seat: it was dropped at whatever height the finger was at, while // it to its seat: it was dropped at whatever height the finger was at, while
@@ -679,118 +665,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
} }
} }
@Composable
private fun MonthContent(
state: MonthUiState,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val fadeSpec = rememberCalendarFadeSpec()
// Loading and failure concern the provider as a whole, so they stand in for
// the pager; a page still loading its own month handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "month-state",
) { s ->
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthPager(
pagerState = pagerState,
weekStart = weekStart,
monthState = monthState,
modifier = Modifier.fillMaxSize(),
) { _, page ->
MonthGrid(
state = page,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
}
}
/**
* The paged and split styles' months as pages that follow the finger and snap
* once a swipe passes half a page or is flung (#336). Only the page on screen
* carries the chip drag and the morph tags: its neighbours show some of its
* dates among their leading and trailing days, and would otherwise claim its
* drops and its shared elements.
*/
@Composable
private fun MonthPager(
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
modifier: Modifier = Modifier,
loading: @Composable () -> Unit = { MonthGridLoading() },
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val chipDrag = LocalMonthDrag.current
HorizontalPager(
state = pagerState,
modifier = modifier,
// The months either side are composed ahead, so a swipe lands on a loaded
// month rather than watching it fill in.
beyondViewportPageCount = 1,
verticalAlignment = Alignment.Top,
// A held chip moves within its month; the page stays put under it.
userScrollEnabled = chipDrag?.isDragging != true,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
MonthPage(
index = index,
active = index == pagerState.currentPage,
morphActive = morphPageActive(index, pagerState.settledPage),
weekStart = weekStart,
monthState = monthState,
loading = loading,
page = page,
)
}
}
@Composable
private fun MonthPage(
index: Int,
active: Boolean,
morphActive: Boolean,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
loading: @Composable () -> Unit,
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val today = LocalMonthToday.current
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
val viewMorph = LocalViewMorph.current
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalMonthMorph provides morph.takeIf { active },
LocalViewMorph provides viewMorph.takeIf { morphActive },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s.withToday(today))
else -> loading()
}
}
}
/** /**
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]: * Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it * vertical scrolling owns the gesture, and the list never swaps wholesale — it
@@ -850,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter())) if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow -> days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) val javaDow = dow.toJavaDayOfWeek()
Text( Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -1069,7 +943,7 @@ internal fun ContinuousMonthGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } val todayMonth = remember(state.today) { state.today.yearMonth }
val timeChipWidth = rememberMonthTimeChipWidth() val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -1463,8 +1337,14 @@ private fun SplitMonthContent(
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag, expandDrag = expandDrag,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
onPaneSwipeNext = onPaneSwipeNext, onPaneSwipeNext = {
onPaneSwipePrev = onPaneSwipePrev, onPaneSwipeNext()
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
},
onPaneSwipePrev = {
onPaneSwipePrev()
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
},
onClearSelection = onClearSelection, onClearSelection = onClearSelection,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -1961,7 +1841,6 @@ private fun SplitDots(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SplitMonthAgendaPane( private fun SplitMonthAgendaPane(
state: MonthUiState.Success, state: MonthUiState.Success,
@@ -1979,33 +1858,16 @@ private fun SplitMonthAgendaPane(
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() } val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
days.forEach { day -> agendaDays(
val events = state.instancesByDay[day].orEmpty() days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
stickyHeader(key = "header-$day") { today = state.today,
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay) zone = state.zone,
} emptyText = R.string.month_split_no_events,
if (events.isEmpty()) { onEmptyClick = onCreateEvent,
item(key = "empty-$day") { dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
AgendaEmptyDayRow( onOpenDay = onOpenDay,
text = stringResource(R.string.month_split_no_events), onEventClick = onEventClick,
onClick = { onCreateEvent(day) }, )
)
}
} else {
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
AgendaEventRow(
event = event,
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
}
} }
} }
@@ -2839,7 +2701,7 @@ private fun DayNumberCell(
private fun shortMonthName(date: LocalDate): String { private fun shortMonthName(date: LocalDate): String {
val locale = currentLocale() val locale = currentLocale()
return remember(date.month, locale) { return remember(date.month, locale) {
java.time.Month.of(date.month.ordinal + 1) date.month.toJavaMonth()
.getDisplayName(JavaTextStyle.SHORT, locale) .getDisplayName(JavaTextStyle.SHORT, locale)
} }
} }
@@ -3002,7 +2864,7 @@ private fun formatMonthTitle(
abbreviated: Boolean = false, abbreviated: Boolean = false,
): String = ): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), date = ym.firstDay.toJavaLocalDate(),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL", skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -8,16 +8,16 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.yearMonth
/** /**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings * A live, scaled-down Month view in a given [MonthViewStyle], for the settings
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
state = sample.continuous, state = sample.continuous,
listState = rememberLazyListState( listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth( initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)), monthIndexOf(today.yearMonth),
), ),
), ),
showWeekNumbers = false, showWeekNumbers = false,
@@ -109,7 +109,7 @@ private fun sampleMonthState(
weekStart: DayOfWeek, weekStart: DayOfWeek,
zone: TimeZone, zone: TimeZone,
): SampleMonth { ): SampleMonth {
val ym = YearMonth(today.year, today.month) val ym = today.yearMonth
val first = LocalDate(ym.year, ym.month, 1) val first = LocalDate(ym.year, ym.month, 1)
val events = sampleMonthEvents(first, today, zone) val events = sampleMonthEvents(first, today, zone)
@@ -1,14 +1,16 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import de.jeanlucmakiola.calendula.ui.common.dayRange
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -19,10 +21,11 @@ import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -33,22 +36,19 @@ import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlinx.datetime.yearMonth
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */ /** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2 private const val MONTH_CACHE_SPAN = 2
@@ -69,54 +69,35 @@ class MonthViewModel @Inject constructor(
/** Whether to fade events that have already finished (display concern only). */ /** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
/** Whether to show the calendar-week number gutter (#25; display only). */ /** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
/** How the grid is laid out and navigated (#38, #53). */ /** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthViewStyle.Paged, initialValue = MonthViewStyle.Paged,
) )
private val todayDate: LocalDate private val todayDate: LocalDate
get() = currentDate() get() = currentDate()
private var lastToday = todayDate
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */ /** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
val today: StateFlow<LocalDate> = todaySource.today val today: StateFlow<LocalDate> =
.onEach { date -> todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/** /**
* The anchor month's state. Once loaded it never falls back to * The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the * [MonthUiState.Loading]: moving the anchor keeps the last result until the
@@ -133,9 +114,8 @@ class MonthViewModel @Inject constructor(
month(ym, ws).filterNot { it is MonthUiState.Loading } month(ym, ws).filterNot { it is MonthUiState.Loading }
} }
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading, initialValue = MonthUiState.Loading,
) )
@@ -170,7 +150,8 @@ class MonthViewModel @Inject constructor(
fun onPageSettled(page: Int) { fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page) _month.value = yearMonthForIndex(page)
realignSelection() // Moving the grid to another month restores its full agenda.
_selectedDate.value = null
} }
// --- Continuous + Dense styles (#38) ---------------------------------- // --- Continuous + Dense styles (#38) ----------------------------------
@@ -195,11 +176,11 @@ class MonthViewModel @Inject constructor(
/** Last reported visible range, so a widening step can re-centre on it. */ /** Last reported visible range, so a widening step can re-centre on it. */
@Volatile @Volatile
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) private var visibleMonths = monthIndexOf(todayDate.yearMonth)
.let { it..it } .let { it..it }
private val _loadedMonths = MutableStateFlow( private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month)) monthIndexOf(todayDate.yearMonth)
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) }, .let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
) )
@@ -215,13 +196,11 @@ class MonthViewModel @Inject constructor(
// Widened to whole grid weeks at both ends: a month block still // Widened to whole grid weeks at both ends: a month block still
// has to know about an event that starts in the boundary week's // has to know about an event that starts in the boundary week's
// clipped-off days, or a bar running into the block would vanish. // clipped-off days, or a bar running into the block would vanish.
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last)) val last = yearMonthForIndex(window.last).lastDay
.plus(1, DateTimeUnit.MONTH) .startOfWeek(ws)
.minus(1, DateTimeUnit.DAY)
.startOfGridWeek(ws)
.plus(6, DateTimeUnit.DAY) .plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) val range = dayRange(first, last, zone)
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
@@ -236,9 +215,8 @@ class MonthViewModel @Inject constructor(
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() } .onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = ContinuousMonthUiState.Loading, initialValue = ContinuousMonthUiState.Loading,
) )
@@ -320,7 +298,7 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at. * silently list a date the grid isn't pointing at.
*/ */
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
val target = YearMonth(date.year, date.month) val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target if (target != _month.value) _month.value = target
} }
@@ -335,17 +313,12 @@ class MonthViewModel @Inject constructor(
} else { } else {
val next = selected.plus(days, DateTimeUnit.DAY) val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next _selectedDate.value = next
_month.value = YearMonth(next.year, next.month) _month.value = next.yearMonth
} }
} }
/** Moving the grid to another month restores its full agenda. */
private fun realignSelection() {
_selectedDate.value = null
}
fun goToToday() { fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month) _month.value = todayDate.yearMonth
_selectedDate.value = null _selectedDate.value = null
} }
@@ -356,8 +329,8 @@ class MonthViewModel @Inject constructor(
*/ */
private fun followToday(old: LocalDate, new: LocalDate) { private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return if (_month.value != old.yearMonth) return
_month.value = YearMonth(new.year, new.month) _month.value = new.yearMonth
_selectedDate.value = null _selectedDate.value = null
} }
@@ -377,7 +350,7 @@ class MonthViewModel @Inject constructor(
/** Jump to the month containing [date] (drawer jump-to-date). */ /** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) { fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month) _month.value = date.yearMonth
_selectedDate.value = date _selectedDate.value = date
} }
@@ -427,7 +400,7 @@ internal fun layoutMonthWeeks(
byDay: DayIndex, byDay: DayIndex,
zone: TimeZone, zone: TimeZone,
): List<MonthWeek> { ): List<MonthWeek> {
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) val gridStart = ym.firstDay.startOfWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart) val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row -> return (0 until weekCount).map { row ->
@@ -548,12 +521,8 @@ internal fun monthGridRange(
weekStart: DayOfWeek, weekStart: DayOfWeek,
zone: TimeZone, zone: TimeZone,
): ClosedRange<Instant> { ): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1) val gridStart = ym.firstDay.startOfWeek(weekStart)
val gridStart = firstOfMonth.startOfGridWeek(weekStart) return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
val start = gridStart.atStartOfDayIn(zone)
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
return start..end
} }
/** /**
@@ -581,7 +550,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
* the first of the visible month. * the first of the visible month.
*/ */
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) { if (today.yearMonth == month) {
today today
} else { } else {
LocalDate(month.year, month.month, 1) LocalDate(month.year, month.month, 1)
@@ -649,8 +618,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
} }
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/** /**
* The Dense style addresses *weeks* by absolute index instead: it has no month * The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it * blocks to key by, and a row's identity has to survive the months around it
@@ -665,12 +632,12 @@ private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31) private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 return base.daysUntil(date.startOfWeek(weekStart)) / 7
} }
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */ /** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int = internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
@@ -681,7 +648,7 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
// The row's midweek day: a boundary week is reported as whichever month owns // The row's midweek day: a boundary week is reported as whichever month owns
// most of it, the same rule the title uses. // most of it, the same rule the title uses.
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY) val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
return monthIndexOf(YearMonth(midweek.year, midweek.month)) return monthIndexOf(midweek.yearMonth)
} }
/** /**
@@ -690,19 +657,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
* laid out too, rather than flickering as placeholders. * laid out too, rather than flickering as placeholders.
*/ */
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) val lastMonthEnd = yearMonthForIndex(window.last).lastDay
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
} }
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym) val first = ym.firstDay
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() return (leadOffset + ym.numberOfDays + 6) / 7
return (leadOffset + daysInMonth + 6) / 7
} }
/** /**
@@ -746,9 +710,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
countByDay = week.countByDay.filterKeys { it in own }, countByDay = week.countByDay.filterKeys { it in own },
) )
} }
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -10,17 +10,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -53,15 +50,10 @@ internal fun BackupStep(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CloudOff) }, hero = { IconHero(Icons.Filled.CloudOff) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_backup_enable_button),
onClick = { runCatching { pickFolder.launch(null) } }, onClick = { runCatching { pickFolder.launch(null) } },
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.onboarding_backup_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton( TextButton(
onClick = onSkip, onClick = onSkip,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -69,20 +61,9 @@ internal fun BackupStep(
Text(stringResource(R.string.onboarding_backup_skip_button)) Text(stringResource(R.string.onboarding_backup_skip_button))
} }
}, },
headline = stringResource(R.string.onboarding_backup_title),
supporting = stringResource(R.string.onboarding_backup_body),
) { ) {
Text(
text = stringResource(R.string.onboarding_backup_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_backup_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow( BenefitRow(
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -30,6 +29,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
import de.jeanlucmakiola.calendula.ui.settings.openUrl import de.jeanlucmakiola.calendula.ui.settings.openUrl
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -108,18 +108,13 @@ internal fun CalendarsStep(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CalendarMonth) }, hero = { IconHero(Icons.Filled.CalendarMonth) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_local_button),
onClick = { onClick = {
onClearCreation() onClearCreation()
creating = true creating = true
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.onboarding_calendars_local_button),
style = MaterialTheme.typography.titleMedium,
)
}
OutlinedButton( OutlinedButton(
onClick = { openUrl(context, DAVX5_URL) }, onClick = { openUrl(context, DAVX5_URL) },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -133,20 +128,9 @@ internal fun CalendarsStep(
Text(stringResource(R.string.onboarding_calendars_skip_button)) Text(stringResource(R.string.onboarding_calendars_skip_button))
} }
}, },
headline = stringResource(R.string.onboarding_calendars_title),
supporting = stringResource(R.string.onboarding_calendars_body),
) { ) {
Text(
text = stringResource(R.string.onboarding_calendars_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (creation is CalendarCreation.Failed) { if (creation is CalendarCreation.Failed) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -189,30 +173,14 @@ private fun CalendarCreated(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CheckCircle) }, hero = { IconHero(Icons.Filled.CheckCircle) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_created_continue),
onClick = onContinue, onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_continue),
style = MaterialTheme.typography.titleMedium,
)
}
}, },
headline = stringResource(R.string.onboarding_calendars_created_title, name),
supporting = stringResource(R.string.onboarding_calendars_created_body),
) { ) {
Text(
text = stringResource(R.string.onboarding_calendars_created_title, name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_created_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow( BenefitRow(
@@ -1,17 +1,10 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -34,28 +27,12 @@ internal fun DoneStep(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { BrandHero(HeroBadge.Done) }, hero = { BrandHero(HeroBadge.Done) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_done_button),
onClick = onFinished, onClick = onFinished,
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.onboarding_done_button),
style = MaterialTheme.typography.titleMedium,
)
}
}, },
) { headline = stringResource(R.string.onboarding_done_title),
Text( supporting = stringResource(R.string.onboarding_done_body),
text = stringResource(R.string.onboarding_done_title), )
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_done_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} }
@@ -7,24 +7,23 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -65,24 +64,17 @@ internal fun HiddenCalendarsStep(
scrollingActions = true, scrollingActions = true,
hero = { IconHero(Icons.Filled.VisibilityOff) }, hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
onClick = if (anyVisible) onFinished else onSwitchAllOn, onClick = if (anyVisible) onFinished else onSwitchAllOn,
modifier = Modifier modifier = Modifier.padding(horizontal = GroupedListInset),
.padding(horizontal = GroupedListInset) )
.fillMaxWidth()
.height(56.dp),
) {
Text(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
style = MaterialTheme.typography.titleMedium,
)
}
// Only while nothing is on: once something is, the step has been // Only while nothing is on: once something is, the step has been
// answered and the one button left to press says so. // answered and the one button left to press says so.
if (!anyVisible) { if (!anyVisible) {
@@ -129,7 +121,7 @@ internal fun HiddenCalendarsStep(
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val off = !calendar.isVisibleInSystem val off = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
summary = calendar.accountName.takeIf { it.isNotBlank() }, summary = calendar.accountName.takeIf { it.isNotBlank() },
position = when { position = when {
calendars.size == 1 -> Position.Alone calendars.size == 1 -> Position.Alone
@@ -140,8 +132,8 @@ internal fun HiddenCalendarsStep(
dimmed = off, dimmed = off,
trailing = if (calendar.hasVisibilitySwitch) { trailing = if (calendar.hasVisibilitySwitch) {
{ {
CalendarSwitch( CalendarVisibilitySwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, calendarName = calendar.displayName.orUnnamed(),
enabled = !off, enabled = !off,
onToggle = { on -> onSetVisible(calendar.id, on) }, onToggle = { on -> onSetVisible(calendar.id, on) },
) )
@@ -156,18 +148,3 @@ internal fun HiddenCalendarsStep(
Spacer(Modifier.height(OnboardingSpace.md)) Spacer(Modifier.height(OnboardingSpace.md))
} }
} }
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
@Composable
private fun CalendarSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -10,7 +10,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
@@ -20,6 +19,8 @@ import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.orderedForManager import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -92,9 +93,8 @@ class OnboardingViewModel @Inject constructor(
list.catch { emit(null) }.flowOn(io) list.catch { emit(null) }.flowOn(io)
} }
} }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
@@ -166,15 +166,6 @@ class OnboardingViewModel @Inject constructor(
hiddenTouched, hiddenTouched,
) { hidden, touched -> if (touched) null else hidden } ) { hidden, touched -> if (touched) null else hidden }
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
private val calendarsFlags: Flow<CalendarsFlags> = combine(
calendarsApplies,
prefs.onboardingCalendarsDone,
hiddenApplies,
prefs.onboardingHiddenCalendarsDone,
::CalendarsFlags,
)
/** /**
* The calendars the hidden-calendars step lists, so the user can judge which * The calendars the hidden-calendars step lists, so the user can judge which
* to switch on rather than take the app's word that there are some. Live: a * to switch on rather than take the app's word that there are some. Live: a
@@ -183,58 +174,44 @@ class OnboardingViewModel @Inject constructor(
*/ */
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
.map { it.orEmpty().orderedForManager() } .map { it.orEmpty().orderedForManager() }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(), initialValue = emptyList(),
) )
private val flags: Flow<OnboardingFlags> = combine( /** What the two calendar steps apply to (#287). */
prefs.reminderOnboardingDone, private val calendarStepsApply: Flow<Pair<Boolean?, Boolean?>> =
prefs.onboardingWizardArmed, combine(calendarsApplies, hiddenApplies, ::Pair)
prefs.onboardingBackupDone,
prefs.onboardingViewDone,
prefs.onboardingMonthStyleDone,
::OnboardingFlags,
)
/** The closing steps' own flags, and whether the notice applies at all. */
private val closingFlags: Flow<ClosingFlags> = combine(
calendarPrefs.visibilityNoticePending.map { it == true },
prefs.onboardingVisibilityDone,
prefs.onboardingDoneShown,
::ClosingFlags,
)
val plan: StateFlow<OnboardingPlan?> = val plan: StateFlow<OnboardingPlan?> =
combine( combine(
hasPermission, hasPermission,
flags, prefs.onboardingProgress,
closingFlags, calendarPrefs.visibilityNoticePending.map { it == true },
backupApplies, backupApplies,
calendarsFlags, calendarStepsApply,
) { granted, stored, closing, backup, calendars -> ) { granted, progress, visibilityArmed, backup, (calendars, hidden) ->
val answered = progress.answered
granted?.let { granted?.let {
onboardingPlan( onboardingPlan(
hasPermission = it, hasPermission = it,
remindersDone = stored.remindersDone, remindersDone = OnboardingStep.Reminders in answered,
wizardArmed = stored.wizardArmed, wizardArmed = progress.wizardArmed,
backupDone = stored.backupDone, backupDone = OnboardingStep.Backup in answered,
viewDone = stored.viewDone, viewDone = OnboardingStep.View in answered,
monthStyleDone = stored.monthStyleDone, monthStyleDone = OnboardingStep.MonthStyle in answered,
backupApplies = backup, backupApplies = backup,
hiddenDone = calendars.hiddenDone, hiddenDone = OnboardingStep.HiddenCalendars in answered,
hiddenApplies = calendars.hiddenApplies, hiddenApplies = hidden,
calendarsDone = calendars.done, calendarsDone = OnboardingStep.Calendars in answered,
calendarsApplies = calendars.applies, calendarsApplies = calendars,
visibilityArmed = closing.visibilityArmed, visibilityArmed = visibilityArmed,
visibilityDone = closing.visibilityDone, visibilityDone = OnboardingStep.Visibility in answered,
doneShown = closing.doneShown, doneShown = OnboardingStep.Done in answered,
) )
} }
}.stateIn( }.stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
@@ -244,9 +221,8 @@ class OnboardingViewModel @Inject constructor(
prefs.monthViewStyle, prefs.monthViewStyle,
prefs.weekStart, prefs.weekStart,
::ViewChoice, ::ViewChoice,
).stateIn( ).stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
@@ -261,7 +237,7 @@ class OnboardingViewModel @Inject constructor(
* to a restarted process, leaving them the reminder step alone. * to a restarted process, leaving them the reminder step alone.
*/ */
private suspend fun armWizardIfFresh() { private suspend fun armWizardIfFresh() {
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard() prefs.armOnboardingWizard()
} }
/** /**
@@ -277,7 +253,7 @@ class OnboardingViewModel @Inject constructor(
fun finishReminders(remindersEnabled: Boolean) { fun finishReminders(remindersEnabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled) prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone() prefs.setOnboardingDone(OnboardingStep.Reminders)
// Nothing else re-arms the scan: turning reminders off cancels the // Nothing else re-arms the scan: turning reminders off cancels the
// alarm (#75). // alarm (#75).
scanner.scan() scanner.scan()
@@ -291,24 +267,13 @@ class OnboardingViewModel @Inject constructor(
* returned to, which [OnboardingPlan.canGoBack] already refuses. * returned to, which [OnboardingPlan.canGoBack] already refuses.
*/ */
fun goBack() { fun goBack() {
viewModelScope.launch { val previous = plan.value?.previous ?: return
when (plan.value?.previous) { viewModelScope.launch { prefs.setOnboardingDone(previous, done = false) }
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
OnboardingStep.Permission, null -> Unit
}
}
} }
/** Close the backup step without setting anything up. */ /** Close the backup step without setting anything up. */
fun skipBackup() { fun skipBackup() {
viewModelScope.launch { prefs.setOnboardingBackupDone() } viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Backup) }
} }
/** /**
@@ -340,7 +305,7 @@ class OnboardingViewModel @Inject constructor(
/** Close the calendar step once its confirmation has been read. */ /** Close the calendar step once its confirmation has been read. */
fun finishCalendars() { fun finishCalendars() {
viewModelScope.launch { viewModelScope.launch {
prefs.setOnboardingCalendarsDone() prefs.setOnboardingDone(OnboardingStep.Calendars)
_creation.value = null _creation.value = null
} }
} }
@@ -355,7 +320,7 @@ class OnboardingViewModel @Inject constructor(
* choice, and so is going off to set up an account and coming back. * choice, and so is going off to set up an account and coming back.
*/ */
fun skipCalendars() { fun skipCalendars() {
viewModelScope.launch { prefs.setOnboardingCalendarsDone() } viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Calendars) }
} }
/** /**
@@ -381,7 +346,7 @@ class OnboardingViewModel @Inject constructor(
) )
// Immediate feedback: the first backup lands while onboarding runs. // Immediate feedback: the first backup lands while onboarding runs.
BackupScheduler.runNow(context) BackupScheduler.runNow(context)
prefs.setOnboardingBackupDone() prefs.setOnboardingDone(OnboardingStep.Backup)
} }
} }
@@ -395,11 +360,11 @@ class OnboardingViewModel @Inject constructor(
/** Close a picker step — the choice itself is saved as it is made. */ /** Close a picker step — the choice itself is saved as it is made. */
fun finishView() { fun finishView() {
viewModelScope.launch { prefs.setOnboardingViewDone() } viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.View) }
} }
fun finishMonthStyle() { fun finishMonthStyle() {
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.MonthStyle) }
} }
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */ /** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
@@ -436,7 +401,7 @@ class OnboardingViewModel @Inject constructor(
/** Close the hidden-calendars step, switched on or left as they were (#287). */ /** Close the hidden-calendars step, switched on or left as they were (#287). */
fun finishHiddenCalendars() { fun finishHiddenCalendars() {
viewModelScope.launch { viewModelScope.launch {
prefs.setOnboardingHiddenCalendarsDone() prefs.setOnboardingDone(OnboardingStep.HiddenCalendars)
// The error belongs to the attempt, not to the step: stepping back // The error belongs to the attempt, not to the step: stepping back
// into it later must not find a refusal nothing has asked for yet. // into it later must not find a refusal nothing has asked for yet.
_hiddenWriteFailed.value = false _hiddenWriteFailed.value = false
@@ -451,7 +416,7 @@ class OnboardingViewModel @Inject constructor(
/** Acknowledge the calendar-visibility notice (#75). */ /** Acknowledge the calendar-visibility notice (#75). */
fun finishVisibility() { fun finishVisibility() {
viewModelScope.launch { viewModelScope.launch {
prefs.setOnboardingVisibilityDone() prefs.setOnboardingDone(OnboardingStep.Visibility)
// Retiring the notice drops its step, so it waits until nothing // Retiring the notice drops its step, so it waits until nothing
// follows — the count must not fall on the way to a later screen. // follows — the count must not fall on the way to a later screen.
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice() if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
@@ -488,36 +453,12 @@ class OnboardingViewModel @Inject constructor(
} }
} }
/** The stored answers the plan is derived from. */
private data class OnboardingFlags(
val remindersDone: Boolean,
val wizardArmed: Boolean,
val backupDone: Boolean,
val viewDone: Boolean,
val monthStyleDone: Boolean,
)
/** How the calendar step's create attempt ended (#287). */ /** How the calendar step's create attempt ended (#287). */
sealed interface CalendarCreation { sealed interface CalendarCreation {
data class Created(val name: String) : CalendarCreation data class Created(val name: String) : CalendarCreation
data object Failed : CalendarCreation data object Failed : CalendarCreation
} }
/** Both calendar steps' inputs (#287). */
private data class CalendarsFlags(
val applies: Boolean?,
val done: Boolean,
val hiddenApplies: Boolean?,
val hiddenDone: Boolean,
)
/** The tail of the flow: the visibility notice and the closing screen. */
private data class ClosingFlags(
val visibilityArmed: Boolean,
val visibilityDone: Boolean,
val doneShown: Boolean,
)
/** What the view step starts from, and renders its previews with. */ /** What the view step starts from, and renders its previews with. */
data class ViewChoice( data class ViewChoice(
val defaultView: CalendarView, val defaultView: CalendarView,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.onboarding
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -13,28 +14,20 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
private val CALENDAR_PERMISSIONS = arrayOf( private val CALENDAR_PERMISSIONS = arrayOf(
@@ -47,9 +40,8 @@ fun PermissionScreen(
onGranted: () -> Unit, onGranted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null, progress: (@Composable () -> Unit)? = null,
viewModel: PermissionViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() var denied by rememberSaveable { mutableStateOf(false) }
// READ and WRITE are requested together (one system dialog — same // READ and WRITE are requested together (one system dialog — same
// permission group), but only READ gates the app: declining write keeps // permission group), but only READ gates the app: declining write keeps
@@ -58,33 +50,27 @@ fun PermissionScreen(
contract = ActivityResultContracts.RequestMultiplePermissions(), contract = ActivityResultContracts.RequestMultiplePermissions(),
) { results -> ) { results ->
if (results[Manifest.permission.READ_CALENDAR] == true) { if (results[Manifest.permission.READ_CALENDAR] == true) {
viewModel.onGranted() onGranted()
} else { } else {
viewModel.onDenied() denied = true
} }
} }
LaunchedEffect(state) { if (denied) {
if (state == PermissionUiState.Granted) onGranted() DeniedContent(
}
when (state) {
is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
progress = progress,
modifier = modifier,
)
is PermissionUiState.Denied -> DeniedContent(
onRetry = { onRetry = {
viewModel.onRetry() denied = false
launcher.launch(CALENDAR_PERMISSIONS) launcher.launch(CALENDAR_PERMISSIONS)
}, },
progress = progress, progress = progress,
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Granted -> { } else {
// Transient — LaunchedEffect above fires and parent replaces us. RationaleContent(
} onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
progress = progress,
modifier = modifier,
)
} }
} }
@@ -100,37 +86,15 @@ private fun RationaleContent(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { BrandHero() }, hero = { BrandHero() },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.permission_request_button),
onClick = onRequest, onClick = onRequest,
modifier = Modifier.fillMaxWidth().height(56.dp), trailingIcon = Icons.AutoMirrored.Filled.ArrowForward,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding, )
) {
Text(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(OnboardingSpace.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}, },
headline = stringResource(R.string.permission_rationale_title),
supporting = stringResource(R.string.permission_rationale_body),
) { ) {
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow( BenefitRow(
@@ -159,7 +123,8 @@ private fun DeniedContent(
progress = progress, progress = progress,
hero = { BrandHero(HeroBadge.Denied) }, hero = { BrandHero(HeroBadge.Denied) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.permission_open_settings_button),
onClick = { onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null) data = Uri.fromParts("package", context.packageName, null)
@@ -167,13 +132,7 @@ private fun DeniedContent(
} }
context.startActivity(intent) context.startActivity(intent)
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.permission_open_settings_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton( TextButton(
onClick = onRetry, onClick = onRetry,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -181,18 +140,7 @@ private fun DeniedContent(
Text(stringResource(R.string.permission_retry_button)) Text(stringResource(R.string.permission_retry_button))
} }
}, },
) { headline = stringResource(R.string.permission_denied_title),
Text( supporting = stringResource(R.string.permission_denied_body),
text = stringResource(R.string.permission_denied_title), )
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} }
@@ -1,7 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
sealed interface PermissionUiState {
data object Rationale : PermissionUiState
data object Denied : PermissionUiState
data object Granted : PermissionUiState
}
@@ -1,29 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class PermissionViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
// also catches grants made outside the app.
fun onGranted() {
_state.value = PermissionUiState.Granted
}
fun onDenied() {
_state.value = PermissionUiState.Denied
}
fun onRetry() {
_state.value = PermissionUiState.Rationale
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.onboarding
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -14,15 +15,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
/** /**
@@ -53,7 +50,8 @@ fun ReminderStep(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.NotificationsActive) }, hero = { IconHero(Icons.Filled.NotificationsActive) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.reminder_onboarding_enable_button),
onClick = { onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
@@ -61,13 +59,7 @@ fun ReminderStep(
onFinished(true) onFinished(true)
} }
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton( TextButton(
onClick = { onFinished(false) }, onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -75,20 +67,9 @@ fun ReminderStep(
Text(stringResource(R.string.reminder_onboarding_skip_button)) Text(stringResource(R.string.reminder_onboarding_skip_button))
} }
}, },
headline = stringResource(R.string.reminder_onboarding_title),
supporting = stringResource(R.string.reminder_onboarding_body),
) { ) {
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow( BenefitRow(
@@ -19,8 +19,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
@@ -28,11 +26,12 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.descriptionRes
import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.ViewPreviewFrame
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -77,11 +76,10 @@ internal fun ViewStep(
} }
} }
IMPLEMENTED_VIEWS.forEachIndexed { index, view -> IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView
GroupedRow( GroupedRow(
title = view.label(), title = view.label(),
position = positionOf(index, IMPLEMENTED_VIEWS.size), position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = isSelected, checked = view == choice.defaultView,
leading = { leading = {
Icon( Icon(
imageVector = view.icon, imageVector = view.icon,
@@ -89,11 +87,6 @@ internal fun ViewStep(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(view) }, onClick = { onSelect(view) },
) )
} }
@@ -127,18 +120,12 @@ internal fun MonthStyleStep(
StepPreview(selected = choice.monthViewStyle) { style -> StepPreview(selected = choice.monthViewStyle) { style ->
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT) MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
} }
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes)) GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
options.forEachIndexed { index, style -> options.forEachIndexed { index, style ->
val isSelected = style == choice.monthViewStyle
GroupedRow( GroupedRow(
title = stringResource(style.labelRes), title = stringResource(style.labelRes),
position = positionOf(index, options.size), position = positionOf(index, options.size),
selected = isSelected, checked = style == choice.monthViewStyle,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(style) }, onClick = { onSelect(style) },
) )
} }
@@ -1,19 +1,12 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -38,28 +31,12 @@ internal fun VisibilityStep(
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.VisibilityOff) }, hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = { actions = {
Button( OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_visibility_button),
onClick = onFinished, onClick = onFinished,
modifier = Modifier.fillMaxWidth().height(56.dp), )
) {
Text(
text = stringResource(R.string.onboarding_visibility_button),
style = MaterialTheme.typography.titleMedium,
)
}
}, },
) { headline = stringResource(R.string.calendars_visibility_notice_title),
Text( supporting = stringResource(R.string.calendars_visibility_notice_message),
text = stringResource(R.string.calendars_visibility_notice_title), )
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.calendars_visibility_notice_message),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} }
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.search package de.jeanlucmakiola.calendula.ui.search
import android.Manifest import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -72,7 +71,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -82,6 +80,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -99,7 +98,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -167,10 +165,7 @@ fun SearchScreen(
pendingWrite = null pendingWrite = null
} }
val requireWrite: (() -> Unit) -> Unit = { action -> val requireWrite: (() -> Unit) -> Unit = { action ->
val granted = ContextCompat.checkSelfPermission( val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) { if (granted) {
action() action()
} else { } else {
@@ -4,17 +4,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchResults import de.jeanlucmakiola.calendula.domain.SearchResults
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -120,7 +119,7 @@ class SearchViewModel @Inject constructor(
} }
.catch { emit(SearchUiState.Idle) } .catch { emit(SearchUiState.Idle) }
.flowOn(io) .flowOn(io)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle) .stateWhileSubscribed(viewModelScope, SearchUiState.Idle)
fun setQuery(value: String) { fun setQuery(value: String) {
if (value != _query.value) _selection.value = emptySet() if (value != _query.value) _selection.value = emptySet()

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