Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 264b2a86c1 | |||
| b03bd67678 | |||
| 301f105fbc | |||
| f0e2e12939 |
@@ -30,7 +30,10 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
|||||||
- Home-screen widget
|
- Home-screen widget
|
||||||
- Full-text search
|
- Full-text search
|
||||||
- Quick-add
|
- Quick-add
|
||||||
- Custom notifications/reminders (system already handles these)
|
- ~~Custom notifications/reminders (system already handles these)~~ —
|
||||||
|
**reversed:** Calendula targets sole-calendar-app users, so no other app
|
||||||
|
posts reminder notifications. We post them ourselves (Etar model). Planned
|
||||||
|
for v1.4 — see `ROADMAP.md`.
|
||||||
- Tablet/foldable-specific layouts
|
- Tablet/foldable-specific layouts
|
||||||
- iOS support (Android-only by design)
|
- iOS support (Android-only by design)
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,35 @@ guide here, not a contract — scope per slice is decided as we go.
|
|||||||
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
|
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||||
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
||||||
|
|
||||||
|
## v1.4 — Reminder Notifications
|
||||||
|
|
||||||
|
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
||||||
|
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
||||||
|
Calendar. The calendar provider schedules reminders and broadcasts
|
||||||
|
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
||||||
|
notification — a calendar app must. We become that app (the Etar model).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
||||||
|
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
||||||
|
no foreground service.
|
||||||
|
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
||||||
|
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
||||||
|
notification channel; tap opens event detail.
|
||||||
|
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
||||||
|
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
||||||
|
toggle, **default ON**, with copy warning that a second calendar app with
|
||||||
|
notifications on will cause duplicate reminders. Mirrored into Settings
|
||||||
|
(reversible).
|
||||||
|
|
||||||
|
Deliberately deferred (add only if needed):
|
||||||
|
- Snooze / dismiss notification actions (Etar has them)
|
||||||
|
- Battery-optimization exemption prompt for delivery reliability
|
||||||
|
|
||||||
## v3.0 — Power-User Features
|
## v3.0 — Power-User Features
|
||||||
|
|
||||||
- Home-screen widget
|
- Home-screen widget
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
||||||
**Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
|
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
|
||||||
Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
|
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
|
||||||
Milestone 2 runs in four slices
|
stale-instances split bugfix). Milestone 2 runs in four slices
|
||||||
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
|
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
|
||||||
(edit event). Note: UI slices now hold release until his explicit approval.
|
conflict dialog, polish) is the remaining slice, v1.4 (reminder
|
||||||
|
notifications) comes first.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -44,8 +45,27 @@ Milestone 2 runs in four slices
|
|||||||
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||||
domain/mapper/repository tests
|
domain/mapper/repository tests
|
||||||
|
|
||||||
|
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
|
||||||
|
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
|
||||||
|
upgrade), dirty-checked partial `update` on the Events row (recurring:
|
||||||
|
series DTSTART moves by the user's delta, DURATION instead of DTEND),
|
||||||
|
reminder diff by minutes (kept rows keep their method), simple recurrence
|
||||||
|
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
|
||||||
|
shown humanized), `EventFormField.Recurrence` incl. settings default,
|
||||||
|
recurrence also available on create; domain/mapper/repository tests.
|
||||||
|
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
|
||||||
|
on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0
|
||||||
|
and made three-way like delete ("this" = exception row via
|
||||||
|
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
|
||||||
|
series update); delete equally three-way (truncation via RRULE UNTIL);
|
||||||
|
the edit-scope question moved to save time (Google model) — dirty
|
||||||
|
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
||||||
|
the "only this event" option
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
|
1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER`
|
||||||
recurrence picker
|
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
|
||||||
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0
|
default-on toggle + duplicate-reminder warning (Etar model)
|
||||||
|
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
|
||||||
|
3. Monitor the F-Droid build/publish for v1.1.0 – v1.3.0
|
||||||
|
|||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminder notifications (v1.4): Calendula now delivers event reminders as
|
||||||
|
notifications itself — the system schedules them but posts nothing, so a
|
||||||
|
calendar app must (essential when Calendula is the only one installed).
|
||||||
|
Due reminders appear on a dedicated "Event reminders" channel; tapping one
|
||||||
|
opens the event's detail screen. Email reminders are never posted (the
|
||||||
|
provider only schedules alert-type reminders)
|
||||||
|
- A one-time onboarding step after the calendar grant introduces reminders,
|
||||||
|
requests the notification permission (Android 13+), and warns that a second
|
||||||
|
calendar app with notifications on will duplicate them. "Not now" leaves
|
||||||
|
the feature off
|
||||||
|
- Settings gained a "Notifications" section mirroring the choice: an event-
|
||||||
|
reminders toggle (default on) with the duplicate-reminders hint; turning it
|
||||||
|
on re-requests the notification permission when missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.4.0 / 12
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Event editing: a pencil action on the detail screen (writable calendars
|
||||||
|
only) opens the event form prefilled with the event. Only fields you
|
||||||
|
actually changed are written back; saving an untouched form is a no-op.
|
||||||
|
Sections holding data are always shown, regardless of the form-field
|
||||||
|
defaults; the calendar itself can't be changed while editing
|
||||||
|
- Recurring events — scoped writes, chosen when saving (Google model):
|
||||||
|
"only this event" (a modified-occurrence exception), "this and all
|
||||||
|
following" (the series is split at the occurrence), or "all events in
|
||||||
|
the series". Changing the recurrence rule rules out "only this event"
|
||||||
|
- Deleting a recurring event gained the middle option too: "this and all
|
||||||
|
following events" ends the series just before the chosen occurrence
|
||||||
|
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
|
||||||
|
presets plus a custom step with interval + unit, weekday toggles for
|
||||||
|
weekly rules ("every week on Mon and Fri"), and an end condition (never /
|
||||||
|
on a date / after a number of times). Rules the picker can't express
|
||||||
|
(e.g. "second Thursday monthly") are shown humanized and preserved
|
||||||
|
verbatim unless replaced. Recurrence also joined the optional form
|
||||||
|
fields and their settings defaults
|
||||||
|
- Validation: a repeat that would end before the event starts is flagged
|
||||||
|
(it would otherwise vanish from every view)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Editing reminders reconciles against the provider's actual rows:
|
||||||
|
reminders you didn't touch keep their method (e.g. email reminders on
|
||||||
|
synced events survive unrelated edits)
|
||||||
|
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
|
||||||
|
action like delete
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Splitting a series ("this and following") sends the complete time-column
|
||||||
|
set in one update, so the provider regenerates its cached instances — an
|
||||||
|
RRULE-only update left a stale duplicate of the tapped occurrence on the
|
||||||
|
split day
|
||||||
|
- RRULE UNTIL values are written as the local end of day expressed in UTC
|
||||||
|
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
|
||||||
|
timezones ahead of UTC
|
||||||
|
- `versionName`/`versionCode` bumped to 1.3.0 / 11
|
||||||
|
|
||||||
## [1.2.1] — 2026-06-11
|
## [1.2.1] — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 10
|
versionCode = 12
|
||||||
versionName = "1.2.1"
|
versionName = "1.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -26,6 +28,20 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.EventReminderReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -7,7 +9,10 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -18,9 +23,16 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
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.
|
||||||
@@ -35,8 +47,51 @@ class MainActivity : ComponentActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
|
if (eventId == -1L) return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||||
|
getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
|
* notifications). The synthetic data URI keys the intent so
|
||||||
|
* PendingIntents for different occurrences never collapse into one.
|
||||||
|
*/
|
||||||
|
fun eventDetailIntent(
|
||||||
|
context: Context,
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
): Intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://event/$eventId/$beginMillis".toUri()
|
||||||
|
putExtra(EXTRA_EVENT_ID, eventId)
|
||||||
|
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
|
||||||
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
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.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -37,6 +39,44 @@ interface CalendarDataSource {
|
|||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/** Insert a new event; returns the new `Events._ID`. */
|
||||||
fun insertEvent(form: EventForm): Long
|
fun insertEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
|
* event, so only fields the user actually changed are written and the
|
||||||
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
*/
|
||||||
|
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
|
* row's `Events._ID`.
|
||||||
|
*/
|
||||||
|
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
|
* by splitting the series: the existing RRULE ends just before the
|
||||||
|
* occurrence and a new event with [updated]'s values (and rule) starts
|
||||||
|
* there; returns the new event's `Events._ID`. From the first occurrence
|
||||||
|
* this is a plain series update. A carried-over COUNT restarts counting
|
||||||
|
* in the new series (we don't recompute the remaining occurrences).
|
||||||
|
*/
|
||||||
|
fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a recurring event from the occurrence at [beginMillis] onwards
|
||||||
|
* by ending the series RRULE just before it. Deleting from the first
|
||||||
|
* occurrence removes the whole event.
|
||||||
|
*/
|
||||||
|
fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
/** Delete the whole event (for recurring events: the entire series). */
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
fun deleteEvent(eventId: Long)
|
fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
@@ -101,7 +141,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||||
|
// (and no DTEND), one-off rows carry DTEND.
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
@@ -128,6 +175,186 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||||
|
val values = buildEventUpdateValues(
|
||||||
|
original = original,
|
||||||
|
updated = updated,
|
||||||
|
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
if (values.isNotEmpty()) {
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
|
}
|
||||||
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
|
// disturb provider rows the form never knew about.
|
||||||
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
|
reconcileReminders(eventId, updated.reminders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||||
|
// The provider clones the series row and applies these values on top.
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form,
|
||||||
|
originalInstanceMillis = beginMillis,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
val uri = resolver.insert(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
|
// business — reconciling against the actual rows handles both ways.
|
||||||
|
reconcileReminders(exceptionId, form.reminders)
|
||||||
|
return exceptionId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
|
// just a series update.
|
||||||
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
|
updateEvent(eventId, original, updated)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
|
val newEventId = insertEvent(updated)
|
||||||
|
truncateSeries(eventId, row, beginMillis)
|
||||||
|
return newEventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// From the first occurrence on = the whole series; also the fallback
|
||||||
|
// when there is no RRULE to truncate.
|
||||||
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
|
deleteEvent(eventId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
truncateSeries(eventId, row, beginMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End [row]'s series just before the occurrence at [beginMillis]. The
|
||||||
|
* provider regenerates an event's cached instances only from the values
|
||||||
|
* carried by the update itself — an RRULE-only update leaves the old
|
||||||
|
* instances standing (observed on-device: the truncated occurrence kept
|
||||||
|
* showing) — so the entire time-related set travels together, with only
|
||||||
|
* the RRULE actually changing.
|
||||||
|
*/
|
||||||
|
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||||
|
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DURATION, row.duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values,
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) {
|
||||||
|
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The series anchor: every time-related column of the Events row. */
|
||||||
|
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
SeriesRow(
|
||||||
|
dtStartMillis = c.getLong(0),
|
||||||
|
rrule = c.getString(1),
|
||||||
|
timezone = c.getString(2),
|
||||||
|
duration = c.getString(3),
|
||||||
|
allDay = c.getInt(4),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} ?: throw WriteFailedException("read series row of event id=$eventId")
|
||||||
|
|
||||||
|
private data class SeriesRow(
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val duration: String?,
|
||||||
|
val allDay: Int,
|
||||||
|
) {
|
||||||
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the event's reminder rows match [targetMinutes]: rows with other
|
||||||
|
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
||||||
|
* (like insertEvent). Rows whose minutes survive keep their method.
|
||||||
|
*/
|
||||||
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
|
val target = targetMinutes.toSet()
|
||||||
|
val existing = queryReminders(eventId).map { it.minutes }.toSet()
|
||||||
|
(existing - target).forEach { minutes ->
|
||||||
|
resolver.delete(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
|
||||||
|
CalendarContract.Reminders.MINUTES + " = ?",
|
||||||
|
arrayOf(eventId.toString(), minutes.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(target - existing).forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
|
ContentValues().also { cv ->
|
||||||
|
forEach { (column, value) ->
|
||||||
|
when (value) {
|
||||||
|
null -> cv.putNull(column)
|
||||||
|
is String -> cv.put(column, value)
|
||||||
|
is Long -> cv.put(column, value)
|
||||||
|
is Int -> cv.put(column, value)
|
||||||
|
else -> error("Unsupported value for $column: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteEvent(eventId: Long) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
val deleted = resolver.delete(
|
val deleted = resolver.delete(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
|||||||
@@ -15,11 +15,37 @@ interface CalendarRepository {
|
|||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an event (recurring: the whole series) from a validated form.
|
||||||
|
* [original] is the prefilled form, used to write only what changed.
|
||||||
|
*/
|
||||||
|
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
*/
|
||||||
|
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
|
* returns the new event's `Events._ID`.
|
||||||
|
*/
|
||||||
|
suspend fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
/** Delete the whole event (for recurring events: the entire series). */
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
suspend fun deleteEvent(eventId: Long)
|
suspend fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
||||||
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
|
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
|
||||||
|
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoSuchEventException(eventId: Long) :
|
class NoSuchEventException(eventId: Long) :
|
||||||
|
|||||||
@@ -74,13 +74,45 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
) = withContext(io) {
|
||||||
|
dataSource.updateEvent(eventId, original, updated)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.updateOccurrence(eventId, beginMillis, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
) = withContext(io) {
|
||||||
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
rawEnd
|
rawEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
|
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@@ -37,6 +38,118 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||||
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
|
* events, seconds otherwise.
|
||||||
|
*/
|
||||||
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||||
|
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||||
|
} else {
|
||||||
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dirty-checked column values for updating an existing Events row: only what
|
||||||
|
* the user actually changed is written, so untouched fields can't stomp
|
||||||
|
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
|
||||||
|
* null value means "set the column to NULL". An empty map means nothing on
|
||||||
|
* the row changed.
|
||||||
|
*
|
||||||
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
|
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||||
|
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||||
|
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||||
|
* past occurrences intact when someone edits a later occurrence's time.
|
||||||
|
*/
|
||||||
|
internal fun buildEventUpdateValues(
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
seriesDtStartMillis: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
if (updated.title.trim() != original.title.trim()) {
|
||||||
|
put(CalendarContract.Events.TITLE, updated.title.trim())
|
||||||
|
}
|
||||||
|
if (updated.location.trim() != original.location.trim()) {
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
if (updated.description.trim() != original.description.trim()) {
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
if (updated.availability != original.availability) {
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
|
||||||
|
}
|
||||||
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
val timesChanged = updated.start != original.start ||
|
||||||
|
updated.end != original.end ||
|
||||||
|
updated.isAllDay != original.isAllDay ||
|
||||||
|
updated.rrule != original.rrule
|
||||||
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
|
||||||
|
if (updated.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.RRULE, null)
|
||||||
|
put(CalendarContract.Events.DURATION, null)
|
||||||
|
} else {
|
||||||
|
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||||
|
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||||
|
put(CalendarContract.Events.DTEND, null)
|
||||||
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a modified-occurrence exception row ("edit only this
|
||||||
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
|
* provider clone the series row and apply these on top. Unlike the series
|
||||||
|
* update there is no dirty check — the exception is a fresh row, so every
|
||||||
|
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||||
|
* clone starts from the parent's values). An exception is a single event:
|
||||||
|
* DTEND, never RRULE/DURATION.
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExceptionValues(
|
||||||
|
form: EventForm,
|
||||||
|
originalInstanceMillis: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
val times = form.toWriteTimes(zone)
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC millis of the last second of the local day *before* the occurrence at
|
||||||
|
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
|
||||||
|
* The provider's recurrence engine applies UNTIL coarsely (observed on a
|
||||||
|
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
|
||||||
|
* series must end on the previous day, not one second before the occurrence.
|
||||||
|
* With no sub-daily frequencies that is semantically the same cut.
|
||||||
|
*/
|
||||||
|
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
|
||||||
|
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
|
||||||
|
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 86_400_000L
|
||||||
|
|
||||||
internal fun Availability.toProviderValue(): Int = when (this) {
|
internal fun Availability.toProviderValue(): Int = when (this) {
|
||||||
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
||||||
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,6 +39,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindCalendarRepository(
|
abstract fun bindCalendarRepository(
|
||||||
impl: CalendarRepositoryImpl,
|
impl: CalendarRepositoryImpl,
|
||||||
): CalendarRepository
|
): CalendarRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindReminderAlertStore(
|
||||||
|
impl: AndroidReminderAlertStore,
|
||||||
|
): ReminderAlertStore
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
|
* onboarding step and Settings warn about duplicates from a second app.
|
||||||
|
*/
|
||||||
|
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDERS_ENABLED_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
|
*/
|
||||||
|
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReminderOnboardingDone() {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
|
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||||
|
*
|
||||||
|
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||||
|
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||||
|
* them, and mark them fired. Posting happens before marking — a crash in
|
||||||
|
* between re-posts silently (same tag) rather than losing the reminder.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class EventReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
|
val readGranted = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!readGranted || !notifier.canPost()) return
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val due = alertStore.dueAlerts(now)
|
||||||
|
due.forEach(notifier::post)
|
||||||
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||||
|
* Stays in the data layer: alerts feed the notification path only and never
|
||||||
|
* reach a screen, so there is no domain model for them.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val alertId: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||||
|
* without a ContentResolver. The provider creates these rows itself — only
|
||||||
|
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||||
|
* email reminders never show up here.
|
||||||
|
*/
|
||||||
|
interface ReminderAlertStore {
|
||||||
|
|
||||||
|
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||||
|
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||||
|
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||||
|
* which the user may have declined — then re-broadcasts silently replace
|
||||||
|
* the already-posted notifications instead (same tag, alert-once).
|
||||||
|
*/
|
||||||
|
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidReminderAlertStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderAlertStore {
|
||||||
|
|
||||||
|
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||||
|
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||||
|
nowMillis.toString(),
|
||||||
|
),
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = c.getLong(0),
|
||||||
|
eventId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||||
|
if (alertIds.isEmpty()) return
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||||
|
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||||
|
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.update(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
values,
|
||||||
|
CalendarContract.CalendarAlerts._ID +
|
||||||
|
" IN (" + alertIds.joinToString(",") + ")",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderAlertStore"
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts._ID,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
|
CalendarContract.CalendarAlerts.END,
|
||||||
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||||
|
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one notification per due reminder alert on a dedicated channel.
|
||||||
|
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||||
|
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||||
|
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||||
|
* duplicating it.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderNotifier @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
|
fun canPost(): Boolean {
|
||||||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(alert: ReminderAlert) {
|
||||||
|
ensureChannel()
|
||||||
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val time = reminderTimeText(
|
||||||
|
beginMillis = alert.beginMillis,
|
||||||
|
endMillis = alert.endMillis,
|
||||||
|
isAllDay = alert.isAllDay,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
)
|
||||||
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setWhen(alert.beginMillis)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||||
|
private fun ensureChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.reminder_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.reminder_channel_description)
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderNotifier"
|
||||||
|
const val CHANNEL_ID = "reminders"
|
||||||
|
|
||||||
|
// One id, distinct tags: the tag (alert id) already keys the
|
||||||
|
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
|
* JVM-tested:
|
||||||
|
*
|
||||||
|
* - timed, same day: "09:30 – 10:00"
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||||
|
* - all-day, one day: "11 Jun 2026"
|
||||||
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
|
*
|
||||||
|
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||||
|
* read in UTC and the end day is the last *covered* day.
|
||||||
|
*/
|
||||||
|
fun reminderTimeText(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
if (isAllDay) {
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||||
|
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.coerceAtLeast(firstDay)
|
||||||
|
return if (lastDay == firstDay) {
|
||||||
|
dateFormat.format(firstDay)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
} else {
|
||||||
|
val dateTimeFormat = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RANGE = " – "
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
@@ -19,6 +24,12 @@ data class EventForm(
|
|||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<Int> = emptyList(),
|
||||||
val availability: Availability = Availability.Busy,
|
val availability: Availability = Availability.Busy,
|
||||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/**
|
||||||
|
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||||
|
* means a one-off event. May hold rules the simple picker can't express —
|
||||||
|
* those are kept verbatim until the user picks something else.
|
||||||
|
*/
|
||||||
|
val rrule: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +40,7 @@ enum class EventFormField {
|
|||||||
Location,
|
Location,
|
||||||
Description,
|
Description,
|
||||||
Reminders,
|
Reminders,
|
||||||
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
}
|
}
|
||||||
@@ -37,6 +49,8 @@ enum class EventFormProblem {
|
|||||||
/** No target calendar — none picked and no writable calendar exists. */
|
/** No target calendar — none picked and no writable calendar exists. */
|
||||||
NoCalendar,
|
NoCalendar,
|
||||||
EndBeforeStart,
|
EndBeforeStart,
|
||||||
|
/** The recurrence's UNTIL date lies before the event's first day. */
|
||||||
|
RecurrenceEndsBeforeStart,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,8 +58,64 @@ enum class EventFormProblem {
|
|||||||
* allowed (display falls back to "(No title)", matching the provider), and a
|
* allowed (display falls back to "(No title)", matching the provider), and a
|
||||||
* zero-length timed event is allowed (spec §8: instant events exist).
|
* zero-length timed event is allowed (spec §8: instant events exist).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
|
||||||
|
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
|
||||||
|
* start — the data layer later turns a time edit into a delta on the series.
|
||||||
|
*
|
||||||
|
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||||
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
|
* the user switches the event to timed.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (instance.isAllDay) {
|
||||||
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||||
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||||
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = instance.calendarId,
|
||||||
|
title = instance.title,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = instance.location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
accessLevel = accessLevel,
|
||||||
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
|
* data they carry would be invisible (though still preserved).
|
||||||
|
*/
|
||||||
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||||
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
|
// An UNTIL before the first day would make the provider generate zero
|
||||||
|
// occurrences — the event would silently vanish from every view.
|
||||||
|
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
|
||||||
|
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
|
||||||
|
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ enum class AccessLevel {
|
|||||||
Confidential,
|
Confidential,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a write to a recurring event reaches. Non-recurring events always
|
||||||
|
* use [AllEvents] (there is only one).
|
||||||
|
*/
|
||||||
|
enum class RecurringWriteScope {
|
||||||
|
ThisEvent,
|
||||||
|
ThisAndFollowing,
|
||||||
|
AllEvents,
|
||||||
|
}
|
||||||
|
|
||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recurrence shapes the simple picker can express (v1.3): a frequency,
|
||||||
|
* an interval, weekly weekday picks, and an optional end. Anything beyond
|
||||||
|
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
|
||||||
|
* RRULE string the picker shows as "custom" and leaves untouched unless the
|
||||||
|
* user replaces it.
|
||||||
|
*/
|
||||||
|
data class SimpleRecurrence(
|
||||||
|
val freq: RecurrenceFreq,
|
||||||
|
val interval: Int = 1,
|
||||||
|
val end: RecurrenceEnd = RecurrenceEnd.Never,
|
||||||
|
/**
|
||||||
|
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
|
||||||
|
* no BYDAY part — the provider derives the day from DTSTART.
|
||||||
|
*/
|
||||||
|
val byDays: Set<DayOfWeek> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RecurrenceFreq {
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface RecurrenceEnd {
|
||||||
|
data object Never : RecurrenceEnd
|
||||||
|
|
||||||
|
/** Last day on which an occurrence may fall (inclusive). */
|
||||||
|
data class Until(val date: LocalDate) : RecurrenceEnd
|
||||||
|
|
||||||
|
/** Total number of occurrences, counting the first. */
|
||||||
|
data class Count(val times: Int) : RecurrenceEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an RRULE into the picker's simple shape, or null when the rule uses
|
||||||
|
* parts the picker can't represent (so the UI preserves the original string).
|
||||||
|
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
|
||||||
|
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
|
||||||
|
* [toRRule].
|
||||||
|
*/
|
||||||
|
fun parseSimpleRecurrence(
|
||||||
|
rrule: String,
|
||||||
|
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
|
): SimpleRecurrence? {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.associate { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) return null
|
||||||
|
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}
|
||||||
|
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val freq = when (parts["FREQ"]?.uppercase()) {
|
||||||
|
"DAILY" -> RecurrenceFreq.Daily
|
||||||
|
"WEEKLY" -> RecurrenceFreq.Weekly
|
||||||
|
"MONTHLY" -> RecurrenceFreq.Monthly
|
||||||
|
"YEARLY" -> RecurrenceFreq.Yearly
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||||
|
|
||||||
|
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||||
|
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
|
||||||
|
val byDays = parts["BYDAY"]?.let { raw ->
|
||||||
|
if (freq != RecurrenceFreq.Weekly) return null
|
||||||
|
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
val until = parts["UNTIL"]
|
||||||
|
val count = parts["COUNT"]
|
||||||
|
if (until != null && count != null) return null
|
||||||
|
val end = when {
|
||||||
|
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
|
||||||
|
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
|
||||||
|
else -> RecurrenceEnd.Never
|
||||||
|
}
|
||||||
|
return SimpleRecurrence(freq, interval, end, byDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
|
||||||
|
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
|
||||||
|
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
|
||||||
|
* engine has been observed applying UNTIL coarsely after converting it into
|
||||||
|
* the event's timezone, so a plain `T235959Z` can leak one extra day for
|
||||||
|
* zones ahead of UTC.
|
||||||
|
*/
|
||||||
|
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
|
||||||
|
append("FREQ=")
|
||||||
|
append(
|
||||||
|
when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> "DAILY"
|
||||||
|
RecurrenceFreq.Weekly -> "WEEKLY"
|
||||||
|
RecurrenceFreq.Monthly -> "MONTHLY"
|
||||||
|
RecurrenceFreq.Yearly -> "YEARLY"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (interval > 1) append(";INTERVAL=$interval")
|
||||||
|
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
|
||||||
|
append(";BYDAY=")
|
||||||
|
append(
|
||||||
|
byDays.sortedBy { it.isoDayNumber }
|
||||||
|
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val e = end) {
|
||||||
|
RecurrenceEnd.Never -> Unit
|
||||||
|
is RecurrenceEnd.Until -> {
|
||||||
|
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
||||||
|
.toInstant(zone)
|
||||||
|
.toLocalDateTime(TimeZone.UTC)
|
||||||
|
append(
|
||||||
|
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.month.number, utc.day,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
|
DayOfWeek.MONDAY to "MO",
|
||||||
|
DayOfWeek.TUESDAY to "TU",
|
||||||
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
DayOfWeek.THURSDAY to "TH",
|
||||||
|
DayOfWeek.FRIDAY to "FR",
|
||||||
|
DayOfWeek.SATURDAY to "SA",
|
||||||
|
DayOfWeek.SUNDAY to "SU",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
|
||||||
|
private fun rruleDay(token: String): DayOfWeek? =
|
||||||
|
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
|
||||||
|
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
|
||||||
|
* Used for "delete this and all following occurrences" — the caller passes a
|
||||||
|
* moment just before the first occurrence to remove.
|
||||||
|
*/
|
||||||
|
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||||
|
val kept = rrule.removePrefix("RRULE:").split(';')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.filterNot { part ->
|
||||||
|
val key = part.substringBefore('=').trim().uppercase()
|
||||||
|
key == "UNTIL" || key == "COUNT"
|
||||||
|
}
|
||||||
|
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
||||||
|
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
until.year, until.month.number, until.day,
|
||||||
|
until.hour, until.minute, until.second,
|
||||||
|
)
|
||||||
|
return (kept + untilPart).joinToString(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
|
||||||
|
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||||
|
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||||
|
*/
|
||||||
|
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
raw.substring(0, 4).toInt(),
|
||||||
|
raw.substring(4, 6).toInt(),
|
||||||
|
raw.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
if (raw.length >= 15 && raw[8] == 'T') {
|
||||||
|
val time = LocalTime(
|
||||||
|
raw.substring(9, 11).toInt(),
|
||||||
|
raw.substring(11, 13).toInt(),
|
||||||
|
raw.substring(13, 15).toInt(),
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
||||||
|
} else {
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -29,9 +30,18 @@ import kotlinx.datetime.LocalDate
|
|||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
|
*
|
||||||
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
|
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||||
|
* so a later recomposition can't re-open it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
fun CalendarHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
@@ -61,6 +71,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tapped reminder notification asks for a specific occurrence.
|
||||||
|
LaunchedEffect(requestedDetailKey) {
|
||||||
|
if (requestedDetailKey != null) {
|
||||||
|
heldKey = requestedDetailKey
|
||||||
|
detailKey = requestedDetailKey
|
||||||
|
onDetailKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
// active and survives view switches. (The calendar filter now lives inline
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
// in the navigation drawer, so no overlay state is needed for it.)
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
@@ -76,6 +95,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
createDateIso = date.toString()
|
createDateIso = date.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||||
|
// recurring events the form itself asks for the write scope at save
|
||||||
|
// time. A saved edit closes the detail screen too: the occurrence the
|
||||||
|
// user tapped may not exist anymore (time moved, recurrence changed), so
|
||||||
|
// falling back to the auto-refreshing calendar is the only honest
|
||||||
|
// destination.
|
||||||
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -117,6 +145,10 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
beginMillis = key[1],
|
beginMillis = key[1],
|
||||||
endMillis = key[2],
|
endMillis = key[2],
|
||||||
onBack = { detailKey = null },
|
onBack = { detailKey = null },
|
||||||
|
onEdit = {
|
||||||
|
heldEditKey = key
|
||||||
|
editKey = key
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +163,26 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = iso,
|
initialDateIso = iso,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
|
onSaved = { createDateIso = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit form (v1.3) — slides over the detail screen.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = editKey != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
editKey = key,
|
||||||
|
onClose = { editKey = null },
|
||||||
|
onSaved = {
|
||||||
|
editKey = null
|
||||||
|
detailKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,22 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
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.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(modifier: Modifier = Modifier) {
|
fun RootScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -40,7 +48,23 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
CalendarHost(modifier = modifier)
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
|
// Null until DataStore's first emission — render nothing for that
|
||||||
|
// frame instead of flashing the wrong screen.
|
||||||
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
when (onboardingDone) {
|
||||||
|
true -> CalendarHost(
|
||||||
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.icu.text.ListFormatter
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||||
|
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
|
||||||
|
* form's repeat card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val freq = parts["FREQ"]?.uppercase()
|
||||||
|
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||||
|
val base = when (freq) {
|
||||||
|
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||||
|
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||||
|
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||||
|
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||||
|
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||||
|
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||||
|
// The day names + their joined block are tracked so only the names (not the
|
||||||
|
// commas/conjunction) can be italicised in the final string.
|
||||||
|
val byDay = parts["BYDAY"]
|
||||||
|
var dayNames: List<String>? = null
|
||||||
|
var joinedDays: String? = null
|
||||||
|
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||||
|
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||||
|
if (days.isNotEmpty()) {
|
||||||
|
val joined = ListFormatter.getInstance(locale).format(days)
|
||||||
|
dayNames = days
|
||||||
|
joinedDays = joined
|
||||||
|
stringResource(R.string.recurrence_on_days, base, joined)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||||
|
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||||
|
val count = parts["COUNT"]?.toIntOrNull()
|
||||||
|
val full = when {
|
||||||
|
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||||
|
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||||
|
else -> main
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(full)
|
||||||
|
val names = dayNames
|
||||||
|
val joined = joinedDays
|
||||||
|
if (names != null && joined != null) {
|
||||||
|
// Italicise each day name within the joined block only — leaving the
|
||||||
|
// separators and conjunction ("und"/"and") in the regular style.
|
||||||
|
val regionStart = full.indexOf(joined)
|
||||||
|
if (regionStart >= 0) {
|
||||||
|
val regionEnd = regionStart + joined.length
|
||||||
|
var cursor = regionStart
|
||||||
|
for (name in names) {
|
||||||
|
val at = full.indexOf(name, cursor)
|
||||||
|
if (at in regionStart until regionEnd) {
|
||||||
|
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||||
|
cursor = at + name.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
"MO" -> DayOfWeek.MONDAY
|
||||||
|
"TU" -> DayOfWeek.TUESDAY
|
||||||
|
"WE" -> DayOfWeek.WEDNESDAY
|
||||||
|
"TH" -> DayOfWeek.THURSDAY
|
||||||
|
"FR" -> DayOfWeek.FRIDAY
|
||||||
|
"SA" -> DayOfWeek.SATURDAY
|
||||||
|
"SU" -> DayOfWeek.SUNDAY
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
|
val digits = raw.takeWhile { it.isDigit() }
|
||||||
|
if (digits.length < 8) return null
|
||||||
|
return try {
|
||||||
|
val date = java.time.LocalDate.of(
|
||||||
|
digits.substring(0, 4).toInt(),
|
||||||
|
digits.substring(4, 6).toInt(),
|
||||||
|
digits.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import android.content.ActivityNotFoundException
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.icu.text.ListFormatter
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -35,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
@@ -74,7 +74,6 @@ import androidx.compose.ui.text.LinkAnnotation
|
|||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -90,13 +89,14 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
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.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
@@ -109,7 +109,9 @@ import kotlin.time.Instant
|
|||||||
* Full-screen event detail (spec S4, realised as a navigation destination
|
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||||
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* be deleted from here (v1.1); edit follows in v1.3.
|
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||||
|
* event form for this occurrence — for recurring events the form asks how
|
||||||
|
* far the change reaches when saving.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -118,6 +120,7 @@ fun EventDetailScreen(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -133,20 +136,35 @@ fun EventDetailScreen(
|
|||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the confirm dialog.
|
// upgrade in place. Granting continues straight into the tapped action.
|
||||||
|
var pendingEdit by remember { mutableStateOf(false) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) showDeleteDialog = true
|
if (granted) {
|
||||||
|
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||||
|
}
|
||||||
|
pendingEdit = false
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val hasWritePermission = {
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
if (granted) {
|
}
|
||||||
|
val onDeleteClick = {
|
||||||
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
showDeleteDialog = true
|
||||||
} else {
|
} else {
|
||||||
|
pendingEdit = false
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onEditClick = {
|
||||||
|
if (hasWritePermission()) {
|
||||||
|
onEdit()
|
||||||
|
} else {
|
||||||
|
pendingEdit = true
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +207,15 @@ fun EventDetailScreen(
|
|||||||
// birthday calendars etc. are read-only at the provider level.
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
val s = state
|
val s = state
|
||||||
if (s is EventDetailUiState.Success && s.canModify) {
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onEditClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
enabled = deleteState != DeleteUiState.Deleting,
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
@@ -223,9 +250,9 @@ fun EventDetailScreen(
|
|||||||
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||||
DeleteEventDialog(
|
DeleteEventDialog(
|
||||||
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||||
onConfirm = { wholeSeries ->
|
onConfirm = { scope ->
|
||||||
showDeleteDialog = false
|
showDeleteDialog = false
|
||||||
viewModel.delete(wholeSeries)
|
viewModel.delete(scope)
|
||||||
},
|
},
|
||||||
onDismiss = { showDeleteDialog = false },
|
onDismiss = { showDeleteDialog = false },
|
||||||
)
|
)
|
||||||
@@ -234,15 +261,16 @@ fun EventDetailScreen(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete confirmation. Recurring events choose between cancelling just the
|
* Delete confirmation. Recurring events choose between cancelling just the
|
||||||
* tapped occurrence (default) and removing the whole series.
|
* tapped occurrence (default), truncating the series from it onwards, and
|
||||||
|
* removing the whole series.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeleteEventDialog(
|
private fun DeleteEventDialog(
|
||||||
isRecurring: Boolean,
|
isRecurring: Boolean,
|
||||||
onConfirm: (wholeSeries: Boolean) -> Unit,
|
onConfirm: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var wholeSeries by rememberSaveable { mutableStateOf(false) }
|
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = {
|
||||||
@@ -258,13 +286,18 @@ private fun DeleteEventDialog(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OptionCard(
|
OptionCard(
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
onClick = { wholeSeries = false },
|
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||||
selected = !wholeSeries,
|
selected = scope == RecurringWriteScope.ThisEvent,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||||
|
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||||
)
|
)
|
||||||
OptionCard(
|
OptionCard(
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
onClick = { wholeSeries = true },
|
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||||
selected = wholeSeries,
|
selected = scope == RecurringWriteScope.AllEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -272,7 +305,9 @@ private fun DeleteEventDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
|
TextButton(
|
||||||
|
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_detail_delete),
|
text = stringResource(R.string.event_detail_delete),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -707,116 +742,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
|
||||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
|
||||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
|
||||||
* monthly/yearly BYDAY, etc.).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
|
||||||
val eq = token.indexOf('=')
|
|
||||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val freq = parts["FREQ"]?.uppercase()
|
|
||||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
|
||||||
val base = when (freq) {
|
|
||||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
|
||||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
|
||||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
|
||||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
|
||||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
|
||||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
|
||||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
|
||||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
|
||||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
|
||||||
// The day names + their joined block are tracked so only the names (not the
|
|
||||||
// commas/conjunction) can be italicised in the final string.
|
|
||||||
val byDay = parts["BYDAY"]
|
|
||||||
var dayNames: List<String>? = null
|
|
||||||
var joinedDays: String? = null
|
|
||||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
|
||||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
|
||||||
if (days.isNotEmpty()) {
|
|
||||||
val joined = ListFormatter.getInstance(locale).format(days)
|
|
||||||
dayNames = days
|
|
||||||
joinedDays = joined
|
|
||||||
stringResource(R.string.recurrence_on_days, base, joined)
|
|
||||||
} else {
|
|
||||||
base
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
base
|
|
||||||
}
|
|
||||||
|
|
||||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
|
||||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
|
||||||
val count = parts["COUNT"]?.toIntOrNull()
|
|
||||||
val full = when {
|
|
||||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
|
||||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
|
||||||
else -> main
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildAnnotatedString {
|
|
||||||
append(full)
|
|
||||||
val names = dayNames
|
|
||||||
val joined = joinedDays
|
|
||||||
if (names != null && joined != null) {
|
|
||||||
// Italicise each day name within the joined block only — leaving the
|
|
||||||
// separators and conjunction ("und"/"and") in the regular style.
|
|
||||||
val regionStart = full.indexOf(joined)
|
|
||||||
if (regionStart >= 0) {
|
|
||||||
val regionEnd = regionStart + joined.length
|
|
||||||
var cursor = regionStart
|
|
||||||
for (name in names) {
|
|
||||||
val at = full.indexOf(name, cursor)
|
|
||||||
if (at in regionStart until regionEnd) {
|
|
||||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
|
||||||
cursor = at + name.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
|
||||||
"MO" -> DayOfWeek.MONDAY
|
|
||||||
"TU" -> DayOfWeek.TUESDAY
|
|
||||||
"WE" -> DayOfWeek.WEDNESDAY
|
|
||||||
"TH" -> DayOfWeek.THURSDAY
|
|
||||||
"FR" -> DayOfWeek.FRIDAY
|
|
||||||
"SA" -> DayOfWeek.SATURDAY
|
|
||||||
"SU" -> DayOfWeek.SUNDAY
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
|
||||||
if (digits.length < 8) return null
|
|
||||||
return try {
|
|
||||||
val date = java.time.LocalDate.of(
|
|
||||||
digits.substring(0, 4).toInt(),
|
|
||||||
digits.substring(4, 6).toInt(),
|
|
||||||
digits.substring(6, 8).toInt(),
|
|
||||||
)
|
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format an event's time into a primary line (date, or "All day") and an
|
* Format an event's time into a primary line (date, or "All day") and an
|
||||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
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
|
||||||
@@ -78,20 +79,23 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the open event. [wholeSeries] is meaningful only for recurring
|
* Delete the open event. [scope] is meaningful only for recurring events
|
||||||
* events: false cancels just the tapped occurrence. Result lands in
|
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
|
||||||
* [deleteState]; the screen consumes it via [consumeDeleteResult].
|
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||||
*/
|
*/
|
||||||
fun delete(wholeSeries: Boolean) {
|
fun delete(scope: RecurringWriteScope) {
|
||||||
val target = _target.value ?: return
|
val target = _target.value ?: return
|
||||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_deleteState.value = DeleteUiState.Deleting
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
_deleteState.value = try {
|
_deleteState.value = try {
|
||||||
if (wholeSeries) {
|
when (scope) {
|
||||||
repository.deleteEvent(target.eventId)
|
RecurringWriteScope.AllEvents ->
|
||||||
} else {
|
repository.deleteEvent(target.eventId)
|
||||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||||
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||||
}
|
}
|
||||||
DeleteUiState.Deleted
|
DeleteUiState.Deleted
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Lock
|
|||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
@@ -88,6 +90,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -101,49 +104,82 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toKotlinDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen event form (v1.2: create only). Opens prefilled from the FAB's
|
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
|
||||||
* anchor date; Save validates, writes via the repository, and closes. The
|
* (v1.3, [editKey] = eventId + the tapped occurrence's begin/end millis).
|
||||||
* calendar picker offers only writable calendars.
|
* Save validates and writes via the repository — a dirty recurring event
|
||||||
|
* first asks how far the change reaches (this occurrence / this and
|
||||||
|
* following / whole series) — then closes. The calendar picker offers only
|
||||||
|
* writable calendars; when editing, the calendar is fixed.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EventEditScreen(
|
fun EventEditScreen(
|
||||||
initialDateIso: String?,
|
initialDateIso: String?,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
onSaved: () -> Unit,
|
||||||
|
editKey: LongArray? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso) {
|
LaunchedEffect(initialDateIso, editKey) {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
if (editKey != null) {
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
viewModel.openForEdit(
|
||||||
viewModel.openNew(date)
|
eventId = editKey[0],
|
||||||
|
beginMillis = editKey[1],
|
||||||
|
endMillis = editKey[2],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
viewModel.openNew(date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// The form is intentionally forgotten on every close (cancel or save) so
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
// the next FAB tap starts clean; it survives rotation because openNew
|
// the next open starts clean; it survives rotation because openNew /
|
||||||
// no-ops while a form is set.
|
// openForEdit no-op while a form is set.
|
||||||
val close = {
|
val close = {
|
||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
|
|
||||||
|
// The event vanished between the detail screen and the edit tap — fall
|
||||||
|
// back to the detail screen, which shows its own failure state.
|
||||||
|
LaunchedEffect(loadFailed) {
|
||||||
|
if (loadFailed) close()
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
@@ -169,7 +205,10 @@ fun EventEditScreen(
|
|||||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||||
LaunchedEffect(state?.saveState) {
|
LaunchedEffect(state?.saveState) {
|
||||||
when (state?.saveState) {
|
when (state?.saveState) {
|
||||||
SaveUiState.Saved -> close()
|
SaveUiState.Saved -> {
|
||||||
|
viewModel.reset()
|
||||||
|
onSaved()
|
||||||
|
}
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||||
@@ -178,7 +217,7 @@ fun EventEditScreen(
|
|||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
}
|
}
|
||||||
SaveUiState.Idle, SaveUiState.Saving, null -> Unit
|
SaveUiState.Idle, SaveUiState.AwaitingScope, SaveUiState.Saving, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +261,53 @@ fun EventEditScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||||
|
SaveScopeDialog(
|
||||||
|
recurrenceChanged = state?.recurrenceChanged == true,
|
||||||
|
onSelect = viewModel::saveWithScope,
|
||||||
|
onDismiss = viewModel::consumeSaveResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope choice when saving a dirty recurring event: one tap writes just the
|
||||||
|
* tapped occurrence, it and everything after (series split), or the whole
|
||||||
|
* series. A changed recurrence rule rules out the single occurrence — an
|
||||||
|
* exception row can't carry its own rule.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SaveScopeDialog(
|
||||||
|
recurrenceChanged: Boolean,
|
||||||
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (!recurrenceChanged) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
||||||
@@ -261,6 +347,7 @@ private fun EventEditContent(
|
|||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -348,12 +435,15 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
|
|
||||||
// Calendar card — tap anywhere to pick the target calendar.
|
// Calendar card — tap anywhere to pick the target calendar. Editing
|
||||||
|
// keeps the owning calendar (moving events between calendars is a
|
||||||
|
// sync-adapter minefield; every stock calendar app locks it too).
|
||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
iconTint = accent,
|
iconTint = accent,
|
||||||
onClick = { showCalendarPicker = true }.takeIf { state.calendars.isNotEmpty() },
|
onClick = { showCalendarPicker = true }
|
||||||
|
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
@@ -438,6 +528,46 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
EditCard(
|
||||||
|
icon = Icons.Default.Repeat,
|
||||||
|
iconContentDescription = null,
|
||||||
|
onClick = { showRecurrencePicker = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = form.rrule?.let { recurrenceText(it, locale) }
|
||||||
|
?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_recurrence),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_error_recurrence_ends_before_start),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
EditCard(
|
EditCard(
|
||||||
@@ -497,7 +627,7 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = state.hasHiddenFields) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showFieldPicker = true },
|
onClick = { showFieldPicker = true },
|
||||||
@@ -561,6 +691,18 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showRecurrencePicker) {
|
||||||
|
RecurrencePickerDialog(
|
||||||
|
current = form.rrule,
|
||||||
|
startDay = form.start.date.dayOfWeek,
|
||||||
|
onSelect = { rrule ->
|
||||||
|
viewModel.setRecurrence(rrule)
|
||||||
|
showRecurrencePicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showRecurrencePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showVisibilityPicker) {
|
if (showVisibilityPicker) {
|
||||||
VisibilityPickerDialog(
|
VisibilityPickerDialog(
|
||||||
selected = form.accessLevel,
|
selected = form.accessLevel,
|
||||||
@@ -574,7 +716,7 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
if (showFieldPicker) {
|
if (showFieldPicker) {
|
||||||
FieldPickerDialog(
|
FieldPickerDialog(
|
||||||
hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields },
|
hiddenFields = state.hiddenFields,
|
||||||
onSelect = { field ->
|
onSelect = { field ->
|
||||||
viewModel.revealField(field)
|
viewModel.revealField(field)
|
||||||
showFieldPicker = false
|
showFieldPicker = false
|
||||||
@@ -660,71 +802,17 @@ private fun ReminderPickerDialog(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// surfaceContainerHighest — the dialog itself sits on
|
DialogAmountField(
|
||||||
// surfaceContainerHigh, so anything lower vanishes.
|
value = amountText,
|
||||||
Surface(
|
onValueChange = { amountText = it },
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
placeholder = "10",
|
||||||
shape = RoundedCornerShape(12.dp),
|
)
|
||||||
) {
|
|
||||||
InlineField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
|
||||||
amountText = text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "10",
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
var unitMenuOpen by remember { mutableStateOf(false) }
|
DialogUnitDropdown(
|
||||||
Box {
|
label = stringResource(reminderUnitLabel(unit)),
|
||||||
Surface(
|
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
onPick = { unit = ReminderUnit.entries[it] },
|
||||||
shape = RoundedCornerShape(12.dp),
|
)
|
||||||
onClick = { unitMenuOpen = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 14.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 12.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(reminderUnitLabel(unit)),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = unitMenuOpen,
|
|
||||||
onDismissRequest = { unitMenuOpen = false },
|
|
||||||
) {
|
|
||||||
ReminderUnit.entries.forEach { entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(reminderUnitLabel(entry))) },
|
|
||||||
onClick = {
|
|
||||||
unit = entry
|
|
||||||
unitMenuOpen = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -743,6 +831,351 @@ private fun ReminderPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
||||||
|
private enum class RecurrenceEndMode { Never, Until, Count }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recurrence picker, two steps like the reminder picker: the plain
|
||||||
|
* frequencies as a tappable list (one tap applies and closes), with "Custom"
|
||||||
|
* switching to an interval-plus-unit editor, weekday toggles (weekly only)
|
||||||
|
* and an end condition — only that step needs an OK button. A rule the
|
||||||
|
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||||
|
* user picks something here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RecurrencePickerDialog(
|
||||||
|
current: String?,
|
||||||
|
startDay: DayOfWeek,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||||
|
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||||
|
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||||
|
var customMode by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) }
|
||||||
|
var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) }
|
||||||
|
// The event's start weekday stands in until the user picks days herself.
|
||||||
|
var daysMask by rememberSaveable {
|
||||||
|
mutableStateOf(
|
||||||
|
(parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var endMode by rememberSaveable {
|
||||||
|
mutableStateOf(
|
||||||
|
when (parsed?.end) {
|
||||||
|
is RecurrenceEnd.Until -> RecurrenceEndMode.Until
|
||||||
|
is RecurrenceEnd.Count -> RecurrenceEndMode.Count
|
||||||
|
else -> RecurrenceEndMode.Never
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var untilIso by rememberSaveable {
|
||||||
|
mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString())
|
||||||
|
}
|
||||||
|
var countText by rememberSaveable {
|
||||||
|
mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString())
|
||||||
|
}
|
||||||
|
var showUntilPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val locale = currentLocale()
|
||||||
|
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
|
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||||
|
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||||
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
|
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||||
|
}
|
||||||
|
val customResult: String? = if (interval != null && customEnd != null) {
|
||||||
|
SimpleRecurrence(
|
||||||
|
freq = freq,
|
||||||
|
interval = interval,
|
||||||
|
end = customEnd,
|
||||||
|
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||||
|
).toRRule()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_detail_recurrence)) },
|
||||||
|
text = {
|
||||||
|
if (!customMode) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_recurrence_none),
|
||||||
|
onClick = { onSelect(null) },
|
||||||
|
selected = current == null,
|
||||||
|
)
|
||||||
|
RecurrenceFreq.entries.forEach { entry ->
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(recurrencePresetLabel(entry)),
|
||||||
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
|
onClick = { customMode = true },
|
||||||
|
selected = current != null && !isPlainPreset,
|
||||||
|
labelColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_every),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogAmountField(
|
||||||
|
value = intervalText,
|
||||||
|
onValueChange = { intervalText = it },
|
||||||
|
placeholder = "1",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(
|
||||||
|
label = stringResource(recurrenceUnitLabel(freq)),
|
||||||
|
entries = RecurrenceFreq.entries.map {
|
||||||
|
stringResource(recurrenceUnitLabel(it))
|
||||||
|
},
|
||||||
|
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (freq == RecurrenceFreq.Weekly) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
WeekdayToggleRow(
|
||||||
|
selected = daysMask.toDaySet(),
|
||||||
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
|
locale = locale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
|
onClick = {
|
||||||
|
endMode = RecurrenceEndMode.Until
|
||||||
|
showUntilPicker = true
|
||||||
|
},
|
||||||
|
supportingText = untilDate?.let {
|
||||||
|
remember(it, locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
.withLocale(locale).format(it.toJavaLocalDate())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_recurrence_end_count),
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Count },
|
||||||
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
)
|
||||||
|
if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = countText,
|
||||||
|
onValueChange = { countText = it },
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_times),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
// The preset list applies on tap; only the custom step needs OK.
|
||||||
|
if (customMode) {
|
||||||
|
TextButton(
|
||||||
|
enabled = customResult != null,
|
||||||
|
onClick = { customResult?.let(onSelect) },
|
||||||
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showUntilPicker) {
|
||||||
|
DatePickerAlert(
|
||||||
|
initial = untilDate ?: LocalDate.fromEpochDays(
|
||||||
|
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
||||||
|
),
|
||||||
|
onConfirm = {
|
||||||
|
untilIso = it.toString()
|
||||||
|
showUntilPicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showUntilPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tappable circle per weekday (locale week order), multi-select — the
|
||||||
|
* BYDAY picks of a weekly rule. Deselecting every day is allowed; the rule
|
||||||
|
* then falls back to the event's start weekday (provider behaviour).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayToggleRow(
|
||||||
|
selected: Set<DayOfWeek>,
|
||||||
|
onToggle: (DayOfWeek) -> Unit,
|
||||||
|
locale: Locale,
|
||||||
|
) {
|
||||||
|
val days = remember(locale) {
|
||||||
|
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
||||||
|
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
|
val isSelected = day in selected
|
||||||
|
Surface(
|
||||||
|
onClick = { onToggle(day) },
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
},
|
||||||
|
contentColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */
|
||||||
|
private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1)
|
||||||
|
|
||||||
|
private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() }
|
||||||
|
|
||||||
|
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||||
|
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||||
|
|
||||||
|
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
||||||
|
@Composable
|
||||||
|
private fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the dialog itself sits on
|
||||||
|
// surfaceContainerHigh, so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) {
|
||||||
|
onValueChange(text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
||||||
|
@Composable
|
||||||
|
private fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 14.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 12.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||||
|
RecurrenceFreq.Weekly -> R.string.recurrence_weekly
|
||||||
|
RecurrenceFreq.Monthly -> R.string.recurrence_monthly
|
||||||
|
RecurrenceFreq.Yearly -> R.string.recurrence_yearly
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> R.string.recurrence_unit_days
|
||||||
|
RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks
|
||||||
|
RecurrenceFreq.Monthly -> R.string.recurrence_unit_months
|
||||||
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
|
}
|
||||||
|
|
||||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
||||||
@@ -793,6 +1226,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
}
|
}
|
||||||
@@ -801,6 +1235,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,26 @@ data class EventEditUiState(
|
|||||||
val saveState: SaveUiState,
|
val saveState: SaveUiState,
|
||||||
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
||||||
val visibleFields: Set<EventFormField> = emptySet(),
|
val visibleFields: Set<EventFormField> = emptySet(),
|
||||||
/** True while at least one optional section hides behind "more fields". */
|
/**
|
||||||
val hasHiddenFields: Boolean = false,
|
* Optional sections behind "more fields". Sections the current mode can't
|
||||||
|
* offer at all (recurrence while editing a single occurrence) appear in
|
||||||
|
* neither list.
|
||||||
|
*/
|
||||||
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||||
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
*/
|
||||||
|
val recurrenceChanged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
sealed interface SaveUiState {
|
sealed interface SaveUiState {
|
||||||
data object Idle : SaveUiState
|
data object Idle : SaveUiState
|
||||||
|
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||||
|
data object AwaitingScope : SaveUiState
|
||||||
data object Saving : SaveUiState
|
data object Saving : SaveUiState
|
||||||
data object Saved : SaveUiState
|
data object Saved : SaveUiState
|
||||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
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
|
||||||
@@ -54,13 +58,32 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// form isn't already shouting errors.
|
// form isn't already shouting errors.
|
||||||
private val _showProblems = MutableStateFlow(false)
|
private val _showProblems = MutableStateFlow(false)
|
||||||
// Fields added through the "more fields" picker; folds back on reset().
|
// Fields added through the "more fields" picker; folds back on reset().
|
||||||
|
// openForEdit seeds it with the sections that already hold values.
|
||||||
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
||||||
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event being edited plus the form exactly as it was prefilled.
|
||||||
|
* For recurring events the write scope is chosen at save time; the
|
||||||
|
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
|
||||||
|
*/
|
||||||
|
private data class EditTarget(
|
||||||
|
val eventId: Long,
|
||||||
|
val original: EventForm,
|
||||||
|
val beginMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
private data class LocalInputs(
|
private data class LocalInputs(
|
||||||
val form: EventForm?,
|
val form: EventForm?,
|
||||||
val saveState: SaveUiState,
|
val saveState: SaveUiState,
|
||||||
val showProblems: Boolean,
|
val showProblems: Boolean,
|
||||||
val revealed: Set<EventFormField>,
|
val revealed: Set<EventFormField>,
|
||||||
|
val editTarget: EditTarget?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
@@ -70,7 +93,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val state: StateFlow<EventEditUiState?> = combine(
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
|
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||||
combine(
|
combine(
|
||||||
repository.calendars()
|
repository.calendars()
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
@@ -92,7 +115,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
|
isEditing = local.editTarget != null,
|
||||||
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
|
recurrenceChanged = local.editTarget != null &&
|
||||||
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
@@ -123,12 +151,38 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget the open form; the next [openNew] starts clean. */
|
/**
|
||||||
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
|
* form is open, so user edits survive configuration changes.
|
||||||
|
*/
|
||||||
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val detail = try {
|
||||||
|
repository.eventDetail(eventId)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_loadFailed.value = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
|
||||||
|
_editTarget.value = EditTarget(eventId, original, beginMillis)
|
||||||
|
// Sections holding data must show even when not in the defaults.
|
||||||
|
_revealed.value = original.populatedFields()
|
||||||
|
_form.value = original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
_form.value = null
|
_form.value = null
|
||||||
_saveState.value = SaveUiState.Idle
|
_saveState.value = SaveUiState.Idle
|
||||||
_showProblems.value = false
|
_showProblems.value = false
|
||||||
_revealed.value = emptySet()
|
_revealed.value = emptySet()
|
||||||
|
_editTarget.value = null
|
||||||
|
_loadFailed.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -144,6 +198,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) = update {
|
fun addReminder(minutes: Int) = update {
|
||||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||||
}
|
}
|
||||||
@@ -158,7 +215,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
|
||||||
/** Validate and write. Terminal results land in [saveState]. */
|
/**
|
||||||
|
* Validate and write. Saving a dirty recurring event pauses in
|
||||||
|
* [SaveUiState.AwaitingScope] until the screen answers via
|
||||||
|
* [saveWithScope]; everything else writes directly. Terminal results
|
||||||
|
* land in [saveState].
|
||||||
|
*/
|
||||||
fun save() {
|
fun save() {
|
||||||
val current = state.value ?: return
|
val current = state.value ?: return
|
||||||
if (current.saveState == SaveUiState.Saving) return
|
if (current.saveState == SaveUiState.Saving) return
|
||||||
@@ -167,11 +229,49 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_showProblems.value = true
|
_showProblems.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val target = _editTarget.value
|
||||||
|
if (target != null && form == target.original) {
|
||||||
|
// A pristine form saves as a no-op instead of a write.
|
||||||
|
_saveState.value = SaveUiState.Saved
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target != null && target.original.rrule != null) {
|
||||||
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performSave(form, RecurringWriteScope.AllEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
|
||||||
|
fun saveWithScope(scope: RecurringWriteScope) {
|
||||||
|
val current = state.value ?: return
|
||||||
|
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||||
|
performSave(current.form, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
|
||||||
|
val target = _editTarget.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_saveState.value = SaveUiState.Saving
|
_saveState.value = SaveUiState.Saving
|
||||||
_saveState.value = try {
|
_saveState.value = try {
|
||||||
repository.createEvent(form)
|
if (target == null) {
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
repository.createEvent(form)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
} else {
|
||||||
|
when (scope) {
|
||||||
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
||||||
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.updateEventFromOccurrence(
|
||||||
|
eventId = target.eventId,
|
||||||
|
beginMillis = target.beginMillis,
|
||||||
|
original = target.original,
|
||||||
|
updated = form,
|
||||||
|
)
|
||||||
|
RecurringWriteScope.AllEvents ->
|
||||||
|
repository.updateEvent(target.eventId, target.original, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
SaveUiState.Saved
|
SaveUiState.Saved
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
internal object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||||
|
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||||
|
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||||
|
* rows fill the width so their own content left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,25 +6,14 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
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.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.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.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
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.CircleShape
|
|
||||||
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.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
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
|
||||||
@@ -42,18 +30,13 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
private val CALENDAR_PERMISSIONS = arrayOf(
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
)
|
)
|
||||||
|
|
||||||
// MD3 8dp spacing scale, scoped to this screen.
|
|
||||||
private object Space {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -118,7 +92,7 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = false) },
|
hero = { BrandHero(denied = false) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -131,7 +105,7 @@ private fun RationaleContent(
|
|||||||
text = stringResource(R.string.permission_request_button),
|
text = stringResource(R.string.permission_request_button),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(Space.xs))
|
Spacer(Modifier.width(OnboardingSpace.xs))
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -147,7 +121,7 @@ private fun RationaleContent(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -161,20 +135,20 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.Lock,
|
icon = Icons.Filled.Lock,
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
body = stringResource(R.string.permission_benefit_sync_body),
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.VisibilityOff,
|
icon = Icons.Filled.VisibilityOff,
|
||||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
@@ -189,7 +163,7 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = true) },
|
hero = { BrandHero(denied = true) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -231,122 +205,6 @@ private fun DeniedContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
|
||||||
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
|
||||||
* centred horizontally; benefit rows fill the width so their own content
|
|
||||||
* left-aligns.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PermissionScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = Space.md, vertical = Space.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = Space.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(Space.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(Space.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
|
||||||
@Composable
|
|
||||||
private fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(Space.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrivacyFootnote() {
|
private fun PrivacyFootnote() {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||||
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
|
* when a second calendar app has notifications on, and requests
|
||||||
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Reminders default ON: [onFinished] gets true from the primary action even
|
||||||
|
* if the system dialog is declined — the OS permission is the real gate, and
|
||||||
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderOnboardingScreen(
|
||||||
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { onFinished(true) }
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BellHero() },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
onFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { onFinished(false) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
|
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.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.NotificationsActive,
|
||||||
|
title = stringResource(R.string.reminder_benefit_delivery_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_delivery_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.ContentCopy,
|
||||||
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
|
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||||
|
@Composable
|
||||||
|
private fun BellHero() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.NotificationsActive,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||||
|
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||||
|
* step neither flashes for users who completed it nor gets skipped.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
|
.map { done -> done as Boolean? }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Close the step, recording whether reminder notifications stay on. */
|
||||||
|
fun finish(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -42,6 +47,7 @@ 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.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
|
||||||
@@ -128,6 +134,13 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_notifications))
|
||||||
|
RemindersRow(
|
||||||
|
checked = state.remindersEnabled,
|
||||||
|
onCheckedChange = viewModel::setRemindersEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_section_language))
|
SectionHeader(stringResource(R.string.settings_section_language))
|
||||||
LanguageRow()
|
LanguageRow()
|
||||||
@@ -249,6 +262,55 @@ private fun DynamicColorRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RemindersRow(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onCheckedChange(enabled)
|
||||||
|
val needsPermission = enabled &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
if (needsPermission) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection() {
|
private fun AboutSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -340,6 +402,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ data class SettingsUiState(
|
|||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
prefs.weekStart,
|
prefs.weekStart,
|
||||||
prefs.defaultFormFields,
|
prefs.defaultFormFields,
|
||||||
) { theme, dynamic, weekStart, formFields ->
|
prefs.remindersEnabled,
|
||||||
|
) { theme, dynamic, weekStart, formFields, reminders ->
|
||||||
SettingsUiState(
|
SettingsUiState(
|
||||||
themeMode = theme,
|
themeMode = theme,
|
||||||
dynamicColor = dynamic && dynamicColorAvailable,
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
dynamicColorAvailable = dynamicColorAvailable,
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
defaultFormFields = formFields,
|
defaultFormFields = formFields,
|
||||||
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification.xml
Normal file
12
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
||||||
|
</vector>
|
||||||
@@ -45,12 +45,15 @@
|
|||||||
|
|
||||||
<!-- Event-Detail-Screen (S4) -->
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
<string name="event_detail_delete">Löschen</string>
|
<string name="event_detail_delete">Löschen</string>
|
||||||
<string name="event_delete_title">Termin löschen?</string>
|
<string name="event_delete_title">Termin löschen?</string>
|
||||||
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||||
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||||
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
|
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
|
||||||
|
<string name="event_delete_option_following">Dieser und alle folgenden Termine</string>
|
||||||
<string name="event_delete_option_series">Alle Termine der Serie</string>
|
<string name="event_delete_option_series">Alle Termine der Serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Wiederkehrenden Termin bearbeiten</string>
|
||||||
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
||||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
<string name="dialog_cancel">Abbrechen</string>
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
@@ -78,6 +81,21 @@
|
|||||||
<string name="reminder_unit_weeks">Wochen</string>
|
<string name="reminder_unit_weeks">Wochen</string>
|
||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||||
|
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||||
|
<string name="event_edit_recurrence_every">Alle</string>
|
||||||
|
<string name="recurrence_unit_days">Tage</string>
|
||||||
|
<string name="recurrence_unit_weeks">Wochen</string>
|
||||||
|
<string name="recurrence_unit_months">Monate</string>
|
||||||
|
<string name="recurrence_unit_years">Jahre</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Endet</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nie</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">An einem Datum</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Nach einer Anzahl</string>
|
||||||
|
<string name="event_edit_recurrence_times">Mal</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Wiederholung endet vor dem Beginn</string>
|
||||||
<string name="event_availability_busy">Beschäftigt</string>
|
<string name="event_availability_busy">Beschäftigt</string>
|
||||||
<string name="event_access_default">Standard</string>
|
<string name="event_access_default">Standard</string>
|
||||||
<string name="event_access_public">Öffentlich</string>
|
<string name="event_access_public">Öffentlich</string>
|
||||||
@@ -141,6 +159,20 @@
|
|||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||||
|
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||||
|
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
|
||||||
|
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
@@ -165,6 +197,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
|
|||||||
@@ -46,12 +46,15 @@
|
|||||||
|
|
||||||
<!-- Event detail screen (S4) -->
|
<!-- Event detail screen (S4) -->
|
||||||
<string name="event_detail_back">Back</string>
|
<string name="event_detail_back">Back</string>
|
||||||
|
<string name="event_detail_edit">Edit</string>
|
||||||
<string name="event_detail_delete">Delete</string>
|
<string name="event_detail_delete">Delete</string>
|
||||||
<string name="event_delete_title">Delete event?</string>
|
<string name="event_delete_title">Delete event?</string>
|
||||||
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||||
<string name="event_delete_recurring_title">Delete recurring event</string>
|
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||||
<string name="event_delete_option_occurrence">Only this event</string>
|
<string name="event_delete_option_occurrence">Only this event</string>
|
||||||
|
<string name="event_delete_option_following">This and all following events</string>
|
||||||
<string name="event_delete_option_series">All events in the series</string>
|
<string name="event_delete_option_series">All events in the series</string>
|
||||||
|
<string name="event_edit_recurring_title">Edit recurring event</string>
|
||||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
<string name="dialog_cancel">Cancel</string>
|
<string name="dialog_cancel">Cancel</string>
|
||||||
@@ -79,6 +82,21 @@
|
|||||||
<string name="reminder_unit_weeks">weeks</string>
|
<string name="reminder_unit_weeks">weeks</string>
|
||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
|
<string name="event_edit_recurrence_every">Every</string>
|
||||||
|
<string name="recurrence_unit_days">days</string>
|
||||||
|
<string name="recurrence_unit_weeks">weeks</string>
|
||||||
|
<string name="recurrence_unit_months">months</string>
|
||||||
|
<string name="recurrence_unit_years">years</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Ends</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Never</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">After a number of times</string>
|
||||||
|
<string name="event_edit_recurrence_times">times</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Repeats end before the event starts</string>
|
||||||
<string name="event_availability_busy">Busy</string>
|
<string name="event_availability_busy">Busy</string>
|
||||||
<string name="event_access_default">Default</string>
|
<string name="event_access_default">Default</string>
|
||||||
<string name="event_access_public">Public</string>
|
<string name="event_access_public">Public</string>
|
||||||
@@ -142,6 +160,20 @@
|
|||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
<!-- Reminder notifications (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Event reminders</string>
|
||||||
|
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||||
|
<string name="reminder_onboarding_title">Never miss an event</string>
|
||||||
|
<string name="reminder_onboarding_body">Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Reminders, delivered</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
@@ -166,6 +198,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
|
<string name="settings_reminders">Event reminders</string>
|
||||||
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
|||||||
@@ -198,6 +198,43 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val original = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Stand-up",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||||
|
)
|
||||||
|
val updated = original.copy(title = "Daily")
|
||||||
|
|
||||||
|
repo.updateEvent(eventId = 42L, original = original, updated = updated)
|
||||||
|
|
||||||
|
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("update event id=42")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X"))
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
@@ -220,6 +257,61 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.deletedEventIds).isEmpty()
|
assertThat(fake.deletedEventIds).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L)
|
||||||
|
assertThat(fake.deletedEventIds).isEmpty()
|
||||||
|
assertThat(fake.deletedOccurrences).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Moved",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(88L)
|
||||||
|
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
|
||||||
|
assertThat(fake.updatedEvents).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val original = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Weekly",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
rrule = "FREQ=WEEKLY",
|
||||||
|
)
|
||||||
|
val updated = original.copy(title = "Weekly, renamed")
|
||||||
|
|
||||||
|
val id = repo.updateEventFromOccurrence(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = 1_000L,
|
||||||
|
original = original,
|
||||||
|
updated = updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(99L)
|
||||||
|
assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated))
|
||||||
|
assertThat(fake.updatedEvents).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ class EventDetailMapperTest {
|
|||||||
assertThat(detail.attendees).isEmpty()
|
assertThat(detail.attendees).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing title stays raw so the edit form does not inherit a placeholder`() {
|
||||||
|
assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty()
|
||||||
|
assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `event color falls back to calendar color when null`() {
|
fun `event color falls back to calendar color when null`() {
|
||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
|
|||||||
@@ -71,4 +71,151 @@ class EventWriteMapperTest {
|
|||||||
// 11th, 12th, 13th inclusive = 3 days.
|
// 11th, 12th, 13th inclusive = 3 days.
|
||||||
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncation cutoff is the end of the previous local day`() {
|
||||||
|
// June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be
|
||||||
|
// June 8 23:59:59 Berlin == June 8 21:59:59Z.
|
||||||
|
assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin))
|
||||||
|
.isEqualTo(1_780_955_999_000L)
|
||||||
|
// All-day series live in UTC: the cutoff for a June 9 UTC-midnight
|
||||||
|
// occurrence is June 8 23:59:59Z.
|
||||||
|
assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC")))
|
||||||
|
.isEqualTo(1_780_963_199_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duration renders seconds for timed and days for all-day events`() {
|
||||||
|
assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false))
|
||||||
|
.isEqualTo("P5400S")
|
||||||
|
assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true))
|
||||||
|
.isEqualTo("P1D")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildEventUpdateValues (dirty-checked partial update) ---
|
||||||
|
|
||||||
|
private val seriesStart = 1_700_000_000_000L
|
||||||
|
|
||||||
|
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
||||||
|
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pristine form produces no values`() {
|
||||||
|
val original = form()
|
||||||
|
assertThat(update(original, original.copy())).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text-only edit writes just the changed columns`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(title = "New", description = "Body"))
|
||||||
|
assertThat(values).containsExactly(
|
||||||
|
CalendarContract.Events.TITLE, "New",
|
||||||
|
CalendarContract.Events.DESCRIPTION, "Body",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing location writes an explicit null`() {
|
||||||
|
val original = form().copy(location = "Berlin")
|
||||||
|
val values = update(original, original.copy(location = " "))
|
||||||
|
assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
|
||||||
|
val original = form()
|
||||||
|
val updated = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
|
||||||
|
)
|
||||||
|
val values = update(original, updated)
|
||||||
|
// 2026-06-11 11:00 CEST == 09:00Z.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(1_781_164_800_000L + 2L * 3_600_000L)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time edit on a recurring event moves the series start by the same delta`() {
|
||||||
|
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val updated = original.copy(
|
||||||
|
// Pushed one hour later than the displayed occurrence.
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||||
|
)
|
||||||
|
val values = update(original, updated)
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5"))
|
||||||
|
// The event was one-off, so the row's DTSTART is the occurrence start
|
||||||
|
// and a zero delta keeps it in place.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing the recurrence writes absolute occurrence times and clears the rule`() {
|
||||||
|
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val values = update(original, original.copy(rrule = null))
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder-only changes touch no event columns`() {
|
||||||
|
val original = form()
|
||||||
|
assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildOccurrenceExceptionValues ("edit only this event") ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception carries absolute times and the original instance`() {
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form().copy(title = "Moved", location = "Berlin"),
|
||||||
|
originalInstanceMillis = 1_700_000_000_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||||
|
.isEqualTo(1_700_000_000_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||||
|
// A single occurrence never carries its own rule.
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception clears empty optionals explicitly`() {
|
||||||
|
// The provider clones the parent row, so a blank field must be an
|
||||||
|
// explicit NULL or the parent's value would survive.
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form(),
|
||||||
|
originalInstanceMillis = 0L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var nextInsertId: Long = 100L
|
var nextInsertId: Long = 100L
|
||||||
|
|
||||||
val insertedForms = mutableListOf<EventForm>()
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
|
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||||
|
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
|
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
|
||||||
private val listeners = mutableListOf<() -> Unit>()
|
private val listeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
@@ -36,6 +40,33 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
updatedEvents += Triple(eventId, original, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateEventFromOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedFromOccurrences += eventId to beginMillis
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteEvent(eventId: Long) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedEventIds += eventId
|
deletedEventIds += eventId
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isTrue()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setRemindersEnabled(false)
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ReminderTimeTextTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
|
||||||
|
dateTime.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private fun utcMidnight(date: LocalDate): Long =
|
||||||
|
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event on one day shows just the time range`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).contains("11.06.2026")
|
||||||
|
assertThat(text).contains("12.06.2026")
|
||||||
|
assertThat(text).contains("23:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day single day shows one date, read in UTC`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
|
isAllDay = true,
|
||||||
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `degenerate all-day range never renders an inverted span`() {
|
||||||
|
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = day,
|
||||||
|
endMillis = day,
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class EventFormTest {
|
class EventFormTest {
|
||||||
|
|
||||||
@@ -69,4 +71,132 @@ class EventFormTest {
|
|||||||
EventFormProblem.EndBeforeStart,
|
EventFormProblem.EndBeforeStart,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence until before the first day is a problem`() {
|
||||||
|
// Days before the start, so it parses to an earlier date in any zone.
|
||||||
|
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
|
||||||
|
assertThat(bad.problems())
|
||||||
|
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence until on or after the first day is fine`() {
|
||||||
|
// Date-only UNTIL parses zone-independently.
|
||||||
|
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
|
||||||
|
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `complex rrules are not validated against the start`() {
|
||||||
|
// The picker can't have produced this ("second Monday" ordinal BYDAY);
|
||||||
|
// it is preserved verbatim and never flagged.
|
||||||
|
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly byday rules are validated against the start`() {
|
||||||
|
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
|
||||||
|
assertThat(bad.problems())
|
||||||
|
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun detail(
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
title: String = "Stand-up",
|
||||||
|
location: String? = "Berlin",
|
||||||
|
description: String? = "Body",
|
||||||
|
rrule: String? = null,
|
||||||
|
reminders: List<Reminder> = emptyList(),
|
||||||
|
availability: Availability = Availability.Busy,
|
||||||
|
accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
): EventDetail = EventDetail(
|
||||||
|
instance = EventInstance(
|
||||||
|
instanceId = 1L,
|
||||||
|
eventId = 1L,
|
||||||
|
calendarId = 7L,
|
||||||
|
title = title,
|
||||||
|
start = Instant.fromEpochMilliseconds(0L),
|
||||||
|
end = Instant.fromEpochMilliseconds(0L),
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
color = 0,
|
||||||
|
location = location,
|
||||||
|
),
|
||||||
|
description = description,
|
||||||
|
organizer = null,
|
||||||
|
attendees = emptyList(),
|
||||||
|
rrule = rrule,
|
||||||
|
reminders = reminders,
|
||||||
|
availability = availability,
|
||||||
|
accessLevel = accessLevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm prefills a timed event from the occurrence times`() {
|
||||||
|
// 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local.
|
||||||
|
val prefilled = detail().toEditForm(
|
||||||
|
beginMillis = 1_781_164_800_000L,
|
||||||
|
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
|
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
||||||
|
assertThat(prefilled.isAllDay).isFalse()
|
||||||
|
assertThat(prefilled.calendarId).isEqualTo(7L)
|
||||||
|
assertThat(prefilled.title).isEqualTo("Stand-up")
|
||||||
|
assertThat(prefilled.location).isEqualTo("Berlin")
|
||||||
|
assertThat(prefilled.description).isEqualTo("Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||||
|
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||||
|
val prefilled = detail(isAllDay = true).toEditForm(
|
||||||
|
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||||
|
endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11))
|
||||||
|
assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13))
|
||||||
|
assertThat(prefilled.isAllDay).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() {
|
||||||
|
val prefilled = detail(
|
||||||
|
rrule = "RRULE:FREQ=WEEKLY",
|
||||||
|
reminders = listOf(
|
||||||
|
Reminder(30, ReminderMethod.Email),
|
||||||
|
Reminder(10, ReminderMethod.Alert),
|
||||||
|
Reminder(30, ReminderMethod.Alert),
|
||||||
|
),
|
||||||
|
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.reminders).containsExactly(10, 30).inOrder()
|
||||||
|
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `populatedFields reports exactly the sections holding values`() {
|
||||||
|
val empty = form().copy(location = "", description = "")
|
||||||
|
assertThat(empty.populatedFields()).isEmpty()
|
||||||
|
|
||||||
|
val full = form().copy(
|
||||||
|
location = "Berlin",
|
||||||
|
description = "Body",
|
||||||
|
reminders = listOf(10),
|
||||||
|
rrule = "FREQ=DAILY",
|
||||||
|
availability = Availability.Free,
|
||||||
|
accessLevel = AccessLevel.Private,
|
||||||
|
)
|
||||||
|
assertThat(full.populatedFields()).containsExactly(
|
||||||
|
EventFormField.Location,
|
||||||
|
EventFormField.Description,
|
||||||
|
EventFormField.Reminders,
|
||||||
|
EventFormField.Recurrence,
|
||||||
|
EventFormField.Availability,
|
||||||
|
EventFormField.Visibility,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class RecurrenceTest {
|
||||||
|
|
||||||
|
private val utc = TimeZone.UTC
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plain frequency parses with defaults`() {
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
|
||||||
|
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
|
||||||
|
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leading RRULE prefix and WKST are tolerated`() {
|
||||||
|
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
|
||||||
|
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interval parses`() {
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
|
||||||
|
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `until parses date-only and UTC datetime forms`() {
|
||||||
|
val expected = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Daily,
|
||||||
|
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
|
||||||
|
)
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
|
||||||
|
.isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `until datetime converts from UTC into the given zone before taking the date`() {
|
||||||
|
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
|
||||||
|
.isEqualTo(
|
||||||
|
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count parses`() {
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
|
||||||
|
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly byday parses as weekday picks`() {
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
|
||||||
|
.isEqualTo(
|
||||||
|
SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Weekly,
|
||||||
|
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rules beyond the simple shape are rejected`() {
|
||||||
|
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
|
||||||
|
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toRRule renders the minimal form`() {
|
||||||
|
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
|
||||||
|
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
|
||||||
|
.isEqualTo("FREQ=DAILY;INTERVAL=3")
|
||||||
|
assertThat(
|
||||||
|
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
|
||||||
|
).isEqualTo("FREQ=MONTHLY;COUNT=12")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toRRule renders weekdays in ISO order regardless of set order`() {
|
||||||
|
val rule = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Weekly,
|
||||||
|
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
|
||||||
|
).toRRule()
|
||||||
|
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
|
||||||
|
val rule = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Monthly,
|
||||||
|
byDays = setOf(DayOfWeek.MONDAY),
|
||||||
|
).toRRule()
|
||||||
|
assertThat(rule).isEqualTo("FREQ=MONTHLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toRRule writes until as the end of the chosen day in the given zone`() {
|
||||||
|
val rule = SimpleRecurrence(
|
||||||
|
RecurrenceFreq.Weekly,
|
||||||
|
interval = 2,
|
||||||
|
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
|
||||||
|
)
|
||||||
|
assertThat(rule.toRRule(utc))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
|
||||||
|
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
|
||||||
|
assertThat(rule.toRRule(berlin))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
|
||||||
|
private val cutoffMillis = 1_781_913_540_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncation replaces count and keeps every other part`() {
|
||||||
|
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
|
||||||
|
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncation replaces an existing until`() {
|
||||||
|
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
|
||||||
|
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncation works on rules the simple picker cannot express`() {
|
||||||
|
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
|
||||||
|
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse and render round-trip`() {
|
||||||
|
val rules = listOf(
|
||||||
|
"FREQ=DAILY",
|
||||||
|
"FREQ=WEEKLY;INTERVAL=2",
|
||||||
|
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
|
||||||
|
"FREQ=MONTHLY;COUNT=6",
|
||||||
|
"FREQ=YEARLY;UNTIL=20301231T235959Z",
|
||||||
|
)
|
||||||
|
rules.forEach { rule ->
|
||||||
|
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
|
||||||
|
}
|
||||||
|
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
|
||||||
|
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
|
||||||
|
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
|
||||||
|
.isEqualTo(berlinRule)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ Domain bleibt pure Kotlin.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||||
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||||
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen |
|
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) |
|
||||||
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
||||||
|
|
||||||
## v1.1 — Write-Fundament + Delete
|
## v1.1 — Write-Fundament + Delete
|
||||||
@@ -95,11 +95,90 @@ Domain bleibt pure Kotlin.
|
|||||||
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
|
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
|
||||||
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
|
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
|
||||||
|
|
||||||
## v1.3 — Edit (Skizze)
|
## v1.3 — Edit
|
||||||
|
|
||||||
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
|
**Domain:**
|
||||||
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
|
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
|
||||||
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
|
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
|
||||||
|
solange der Picker sie nicht ersetzt
|
||||||
|
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
|
||||||
|
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
|
||||||
|
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
|
||||||
|
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
|
||||||
|
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
|
||||||
|
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
|
||||||
|
null Vorkommen — Event würde unsichtbar)
|
||||||
|
|
||||||
|
**Data layer:**
|
||||||
|
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)` —
|
||||||
|
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
|
||||||
|
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
|
||||||
|
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
|
||||||
|
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
|
||||||
|
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
|
||||||
|
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
|
||||||
|
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
|
||||||
|
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
|
||||||
|
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
|
||||||
|
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
|
||||||
|
Werten werden unabhängig vom Settings-Default eingeblendet
|
||||||
|
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
|
||||||
|
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
|
||||||
|
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
|
||||||
|
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
|
||||||
|
OptionCard-Stil)
|
||||||
|
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
|
||||||
|
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
|
||||||
|
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
|
||||||
|
getappte Occurrence existiert danach evtl. nicht mehr)
|
||||||
|
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
|
||||||
|
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
|
||||||
|
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
|
||||||
|
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
|
||||||
|
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
|
||||||
|
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
|
||||||
|
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
|
||||||
|
Formularwerte, leere Optionals als explizite NULLs weil der Provider
|
||||||
|
die Serien-Row klont), Reminder werden gegen die tatsächlichen
|
||||||
|
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
|
||||||
|
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
|
||||||
|
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
|
||||||
|
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
|
||||||
|
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
|
||||||
|
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
|
||||||
|
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
|
||||||
|
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
|
||||||
|
löschen
|
||||||
|
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
|
||||||
|
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
|
||||||
|
adb-Probe verifiziert): der Provider regeneriert die Instances eines
|
||||||
|
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
|
||||||
|
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
|
||||||
|
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
|
||||||
|
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
|
||||||
|
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
|
||||||
|
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
|
||||||
|
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
|
||||||
|
statt Occurrence−1s, und der Recurrence-Picker rendert UNTIL als
|
||||||
|
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
|
||||||
|
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
|
||||||
|
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
|
||||||
|
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
|
||||||
|
- [x] Strings DE+EN
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
|
||||||
|
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
|
||||||
|
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
|
||||||
|
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
|
||||||
|
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
|
||||||
|
- [x] `EventDetailMapperTest`: roher Titel
|
||||||
|
|
||||||
|
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
||||||
|
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
||||||
|
|
||||||
## v2.0 — Abschluss (Skizze)
|
## v2.0 — Abschluss (Skizze)
|
||||||
|
|
||||||
|
|||||||
119
docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Normal file
119
docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Calendula - Plan 04: Reminder Notifications (v1.4)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
|
||||||
|
Der Provider plant die Alarme und broadcastet
|
||||||
|
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
|
||||||
|
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
|
||||||
|
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
|
||||||
|
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||||
|
On-Device-Review.
|
||||||
|
|
||||||
|
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
|
||||||
|
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
|
||||||
|
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
|
||||||
|
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
|
||||||
|
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
|
||||||
|
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
|
||||||
|
|
||||||
|
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
|
||||||
|
|
||||||
|
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
|
||||||
|
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
|
||||||
|
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
|
||||||
|
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
|
||||||
|
`METHOD_ALERT`.
|
||||||
|
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
|
||||||
|
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
|
||||||
|
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
|
||||||
|
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
|
||||||
|
"fällig & noch SCHEDULED".
|
||||||
|
3. Etar postet aus dem Zustand `SCHEDULED ∪ FIRED` und verwaltet Dismiss über
|
||||||
|
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
|
||||||
|
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
|
||||||
|
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
|
||||||
|
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
|
||||||
|
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
|
||||||
|
`setOnlyAlertOnce`).
|
||||||
|
|
||||||
|
**Leitentscheidungen:**
|
||||||
|
|
||||||
|
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
|
||||||
|
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
|
||||||
|
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
|
||||||
|
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
|
||||||
|
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
|
||||||
|
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
|
||||||
|
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
|
||||||
|
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
|
||||||
|
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
|
||||||
|
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
|
||||||
|
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
|
||||||
|
v1.0–v1.3-Upgrader, die das Feature so entdecken.
|
||||||
|
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
|
||||||
|
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
|
||||||
|
wenn sie fehlt.
|
||||||
|
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
|
||||||
|
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
|
||||||
|
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
|
||||||
|
der Detail-Overlay selbst).
|
||||||
|
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
|
||||||
|
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
|
||||||
|
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
|
||||||
|
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
|
||||||
|
Vollbild-Alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
**Manifest / Resourcen:**
|
||||||
|
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
|
||||||
|
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
|
||||||
|
host=com.android.calendar`; `MainActivity` → `launchMode="singleTop"`
|
||||||
|
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
|
||||||
|
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
|
||||||
|
|
||||||
|
**Prefs:**
|
||||||
|
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
|
||||||
|
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
|
||||||
|
|
||||||
|
**Data layer (`data/reminders/`):**
|
||||||
|
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
|
||||||
|
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
|
||||||
|
title, location, isAllDay
|
||||||
|
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
|
||||||
|
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
|
||||||
|
`STATE_SCHEDULED AND ALARM_TIME <= now`;
|
||||||
|
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
|
||||||
|
`SecurityException` → Log (Write-Recht optional)
|
||||||
|
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
|
||||||
|
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
|
||||||
|
Category EVENT), Content-PendingIntent auf `MainActivity` mit
|
||||||
|
eventId/begin/end
|
||||||
|
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
|
||||||
|
|
||||||
|
**Receiver:**
|
||||||
|
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
|
||||||
|
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
|
||||||
|
Notifications systemseitig geblockt; sonst posten → `markFired`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
|
||||||
|
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
|
||||||
|
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
|
||||||
|
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
|
||||||
|
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
|
||||||
|
setzen `reminder_onboarding_done`
|
||||||
|
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
|
||||||
|
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
|
||||||
|
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
|
||||||
|
onNewIntent) in Compose-State
|
||||||
|
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
|
||||||
|
Permission-Request beim Einschalten) + Duplikat-Hinweistext
|
||||||
|
|
||||||
|
**Abschluss:**
|
||||||
|
- [x] `./gradlew lint test assembleDebug` grün
|
||||||
|
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
|
||||||
|
On-Device-Review
|
||||||
Reference in New Issue
Block a user