Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df |
41
.gitea/workflows/translations.yaml
Normal file
41
.gitea/workflows/translations.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
paths:
|
||||||
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
- 'scripts/check_translations.py'
|
||||||
|
- '.gitea/workflows/translations.yaml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translations-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure python3
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check translation parity
|
||||||
|
run: python3 scripts/check_translations.py
|
||||||
@@ -232,10 +232,19 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||||
|
|
||||||
**Tier 4 — interop & bigger-ticket**
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
9. **Reminders — defaults + delivery reliability** *(next)* — global default
|
||||||
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
reminder **+ per-calendar override**, bundled with exact-alarm / battery
|
||||||
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
hardening. Elevated above .ics: it's core to the "Calendula is your only
|
||||||
|
calendar app" promise. Full sketch in "Reminders — defaults & delivery
|
||||||
|
reliability" below.
|
||||||
|
10. **Local-calendar backup / export** — device-only calendars have no sync and
|
||||||
|
therefore **no backup**; losing the phone = total data loss. Whole-calendar
|
||||||
|
`.ics` export + restore. A data-integrity gap, not a feature; front-runs and
|
||||||
|
overlaps the single-event .ics work below.
|
||||||
|
11. Share event as .ics + receive/open .ics into a prefilled create form
|
||||||
|
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||||
|
13. Snooze / dismiss notification actions — follows the reminders slice (#9)
|
||||||
|
|
||||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
@@ -249,8 +258,9 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||||
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
|
||||||
|
whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||||
|
|
||||||
## Navigation & views
|
## Navigation & views
|
||||||
|
|
||||||
@@ -260,9 +270,14 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
natural data source for a future widget)
|
natural data source for a future widget)
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
||||||
|
currently absent. Daily-driver polish.
|
||||||
|
- Week numbers in the **month** grid — week view already shows the badge
|
||||||
|
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||||
- Pinch-to-zoom time scale in day/week
|
- Pinch-to-zoom time scale in day/week
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
- Full-text search *(was v3.0)*
|
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||||
|
with real event history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
## Event editing & creation
|
## Event editing & creation
|
||||||
|
|
||||||
@@ -297,12 +312,103 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
go/no-go gate as the OSM/INTERNET item below.
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
- Move event to another calendar (copy+delete model with a consequences
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
|
||||||
|
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
|
||||||
|
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
|
||||||
|
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
|
||||||
|
restore-on-import that recreates events into a chosen local calendar. Reuses
|
||||||
|
the .ics serializer from the single-event share work; the restore path reuses
|
||||||
|
the import parser. A data-integrity obligation, not a feature.
|
||||||
|
|
||||||
## Reminders, round two
|
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||||
|
|
||||||
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
|
the "Calendula is your only calendar app" promise.
|
||||||
|
|
||||||
|
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||||
|
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||||
|
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||||
|
toggle handling; three pickers + per-calendar override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||||
|
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||||
|
Resolution model: all-day events use the all-day global default outright;
|
||||||
|
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||||
|
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||||
|
inline wrapper + single combined flow read.
|
||||||
|
|
||||||
|
**Deliberately deferred (documented decisions, not oversights):**
|
||||||
|
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||||
|
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||||
|
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||||
|
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||||
|
(open decision #1). The battery exemption is the reliability lever; no
|
||||||
|
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||||
|
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||||
|
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||||
|
the change reviewable).
|
||||||
|
|
||||||
|
### A. Default reminders (global + per-calendar override)
|
||||||
|
|
||||||
|
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||||
|
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||||
|
So both the global default *and* the per-calendar override are **app-side
|
||||||
|
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||||
|
synced calendar.
|
||||||
|
|
||||||
|
- **Storage (DataStore):**
|
||||||
|
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||||
|
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||||
|
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||||
|
minutes before a start instant — they need their own value).
|
||||||
|
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||||
|
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||||
|
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||||
|
- **Apply on create:** a fresh event prefills its reminders list from
|
||||||
|
override-or-global for the preselected calendar. Changing the calendar in the
|
||||||
|
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||||
|
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||||
|
reset pattern (v2.4).
|
||||||
|
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||||
|
reminders on existing events on open or on calendar-switch-during-edit.
|
||||||
|
- **Settings UI (Notifications sub-page):**
|
||||||
|
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||||
|
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||||
|
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||||
|
or a Notifications subsection), each opening the same OptionCard with a
|
||||||
|
leading **"Use global default"** option.
|
||||||
|
|
||||||
|
### B. Delivery reliability (exact alarms + battery)
|
||||||
|
|
||||||
|
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||||
|
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||||
|
it directly undermines the feature's premise, so it rides in here.
|
||||||
|
|
||||||
|
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||||
|
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||||
|
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||||
|
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||||
|
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
||||||
|
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||||
|
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||||
|
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||||
|
exempt Calendula for on-time delivery." Shown once after the existing
|
||||||
|
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||||
|
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||||
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
|
are notorious for suppressing it).
|
||||||
|
|
||||||
|
### Open decisions (resolve before building)
|
||||||
|
|
||||||
|
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||||
|
(reliability vs simplicity + battery cost).
|
||||||
|
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||||
|
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||||
|
a list inside the Notifications sub-page).
|
||||||
|
|
||||||
|
### Later (round two)
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
- Snooze + dismiss actions on the notification (snooze needs an
|
||||||
exact-alarm / WorkManager decision)
|
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||||
- Settings default reminder applied to new events
|
|
||||||
|
|
||||||
## Sharing & interop
|
## Sharing & interop
|
||||||
|
|
||||||
@@ -312,8 +418,18 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
|
|
||||||
## Platform & launchers
|
## Platform & launchers
|
||||||
|
|
||||||
- Home-screen widget *(was v3.0)*
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||||
|
optional quick-settings tile still open
|
||||||
|
|
||||||
|
## Quality & reliability
|
||||||
|
|
||||||
|
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||||
|
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||||
|
F-Droid app; nothing tracks it yet.
|
||||||
|
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||||
|
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||||
|
above (Tier 4 #9).
|
||||||
|
|
||||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
|
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.6.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App language can now be set from Android's system per-app language settings
|
||||||
|
(Android 13+), in addition to the in-app picker in Settings — and the app is
|
||||||
|
set up so further languages can be added by community translators
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changing the app language in Settings now takes effect immediately; the
|
||||||
|
picker previously had no effect
|
||||||
|
|
||||||
## [2.5.0] — 2026-06-17
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20500
|
versionCode = 20600
|
||||||
versionName = "2.5.0"
|
versionName = "2.6.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,15 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
|
// check_translations.py guards the same invariants with clearer,
|
||||||
|
// translator-facing messages.
|
||||||
|
informational += "MissingTranslation"
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
<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" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
delayed by Doze). Used only to launch that dialog; falls back to the
|
||||||
|
battery-optimisation list if the OS declines the direct intent.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
@@ -25,6 +32,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Calendula"
|
android:theme="@style/Theme.Calendula"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
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
|
||||||
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
|
* speaks (whole days before the event — "1 day before") and the **raw**
|
||||||
|
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
|
||||||
|
*
|
||||||
|
* Calendula schedules no alarms itself: the provider fires a reminder at
|
||||||
|
* `DTSTART − MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
|
||||||
|
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
|
||||||
|
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
|
||||||
|
*
|
||||||
|
* To fire at a chosen wall-clock time we encode that time *into* the offset:
|
||||||
|
* `MINUTES = UTC-midnight(startDate) − (localInstant of [timeOfDayMinutes] on the
|
||||||
|
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
|
||||||
|
* the event's own date, so a recurring all-day series or a post-creation
|
||||||
|
* timezone change drifts the fire time by the offset delta (±1h across DST) —
|
||||||
|
* an inherent limit of the provider model, shared by Etar.
|
||||||
|
*/
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
|
||||||
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
|
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||||
|
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||||
|
* through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun toProviderAllDayMinutes(
|
||||||
|
semanticMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
timeOfDayMinutes: Int,
|
||||||
|
): Int {
|
||||||
|
if (semanticMinutes < 0) return semanticMinutes
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||||
|
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||||
|
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||||
|
* after DTSTART) folds to day 0.
|
||||||
|
*/
|
||||||
|
internal fun fromProviderAllDayMinutes(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Int {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||||
|
.atZone(zone).toLocalDate()
|
||||||
|
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ 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 de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -60,24 +61,40 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/**
|
||||||
fun insertEvent(form: EventForm): Long
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
|
* should fire at — encoded into each all-day reminder's provider offset
|
||||||
|
* (ignored for timed events).
|
||||||
|
*/
|
||||||
|
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing event (for recurring events: the whole series) to
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
* match [updated]. [original] is the form as it was prefilled from the
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
* event, so only fields the user actually changed are written and the
|
* event, so only fields the user actually changed are written and the
|
||||||
* reminder rows can be diffed instead of wiped.
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
* row's `Events._ID`.
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
@@ -92,6 +109,7 @@ interface CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,7 +283,33 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
private data class CalendarAccount(val name: String, val type: String)
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
/**
|
||||||
|
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||||
|
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
|
||||||
|
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
|
||||||
|
*/
|
||||||
|
private fun providerReminderMinutes(
|
||||||
|
form: EventForm,
|
||||||
|
minutes: Int,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Int = if (form.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = form.start.date.toJavaLocalDate(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||||
|
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||||
|
form.reminders
|
||||||
|
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(
|
put(
|
||||||
@@ -303,7 +347,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
// Best effort (spec §8): the event exists at this point — a reminder
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// that fails to attach is logged, not surfaced as a failed create.
|
||||||
form.reminders.distinct().forEach { minutes ->
|
encodedReminders(form, allDayReminderTimeMinutes)
|
||||||
|
.forEach { minutes ->
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
@@ -316,7 +361,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
@@ -332,13 +382,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
}
|
}
|
||||||
// Untouched reminder sets are left alone so unrelated edits can't
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
// disturb provider rows the form never knew about.
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
|
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, updated.reminders)
|
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -352,7 +408,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, form.reminders)
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,16 +417,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on (or with no rule to split) this is
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
// just a series update.
|
// just a series update.
|
||||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
updateEvent(eventId, original, updated)
|
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
@@ -456,9 +513,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
* are baked in and the diff matches the stored rows). Rows with other offsets
|
||||||
|
* 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>) {
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
val target = targetMinutes.toSet()
|
val target = targetMinutes.toSet()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -28,9 +30,14 @@ import javax.inject.Singleton
|
|||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||||
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
@@ -93,7 +100,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEvent(
|
override suspend fun updateEvent(
|
||||||
@@ -101,7 +108,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.updateEvent(eventId, original, updated)
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
@@ -113,7 +120,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -122,7 +129,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
dataSource.updateEventFromOccurrence(
|
||||||
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
instanceId = eventId,
|
instanceId = eventId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
title = title,
|
title = title,
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// All-day reminders are stored as a wall-clock-shifted offset (see
|
||||||
|
// AllDayReminderEncoding); decode back to the whole-day lead time the form
|
||||||
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
|
// event's date is its UTC date.
|
||||||
|
val displayReminders = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
|
} else {
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
reminders = reminders,
|
reminders = displayReminders,
|
||||||
status = status,
|
status = status,
|
||||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
// default these mappers already return — no isNull guard needed.
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -127,6 +128,97 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes before start) prefilled on new
|
||||||
|
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||||
|
* as the factory default so existing users aren't surprised by reminders they
|
||||||
|
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||||
|
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||||
|
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time prefilled on new **all-day** events, in
|
||||||
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
|
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||||
|
* to all-day events — they always use this global value.
|
||||||
|
*/
|
||||||
|
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||||
|
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
|
||||||
|
* before" would fire at an off hour (02:00 local in CEST); this time is
|
||||||
|
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
|
||||||
|
* day before instead. Global for every all-day reminder; default 09:00.
|
||||||
|
* Stored/clamped to a valid 0..1439 minute-of-day.
|
||||||
|
*/
|
||||||
|
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
|
* timed default for its new events: a `null` value means "no reminder", an int
|
||||||
|
* means that lead time. A calendar **absent** from the map inherits the global
|
||||||
|
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||||
|
* no-reminder override. (All-day events ignore this and use
|
||||||
|
* [defaultAllDayReminderMinutes].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
|
||||||
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
|
* inherit the global all-day default; present null = no reminder).
|
||||||
|
*/
|
||||||
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current =
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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(',')
|
||||||
@@ -143,10 +235,90 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
|
internal val DEFAULT_ALLDAY_REMINDER_KEY =
|
||||||
|
stringPreferencesKey("default_allday_reminder_minutes")
|
||||||
|
internal val ALLDAY_REMINDER_TIME_KEY =
|
||||||
|
intPreferencesKey("allday_reminder_time_minutes")
|
||||||
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
|
sealed interface CalendarReminderOverride {
|
||||||
|
/** No override — the calendar uses the global default. */
|
||||||
|
data object Inherit : CalendarReminderOverride
|
||||||
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
|
data object None : CalendarReminderOverride
|
||||||
|
/** A specific lead time in minutes before the event start. */
|
||||||
|
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead time to prefill on a new event: the matching per-calendar override
|
||||||
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
|
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||||
|
* it can be unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultReminder(
|
||||||
|
timedGlobal: Int?,
|
||||||
|
allDayGlobal: Int?,
|
||||||
|
timedOverrides: Map<Long, Int?>,
|
||||||
|
allDayOverrides: Map<Long, Int?>,
|
||||||
|
calendarId: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
): Int? {
|
||||||
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
|
overrides[calendarId]
|
||||||
|
} else {
|
||||||
|
global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||||
|
private fun MutableMap<Long, Int?>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, null)
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NONE = "none"
|
||||||
|
private const val ENTRY_SEP = ";"
|
||||||
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
|
||||||
|
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||||
|
null, "", NONE -> null
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
id to value
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
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 and pickers. */
|
||||||
|
@Composable
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -102,7 +104,19 @@ fun CollapsingScaffold(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
content = content,
|
content = content,
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FullScreenPicker(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||||
|
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||||
|
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||||
|
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||||
|
add(CalendarReminderOverride.None)
|
||||||
|
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(
|
||||||
|
R.string.reminder_custom_with_value,
|
||||||
|
reminderLeadTimeLabel(selectedMinutes!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_edit_reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = customExpanded) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
* container). An amount field with a live preview of the resulting lead time, a
|
||||||
|
* single-choice unit toggle, and a tonal confirm enabled only for a valid
|
||||||
|
* 1–999 amount. [onConfirm] receives the final lead time in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedCheck() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||||
|
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||||
|
minutes == null -> "" to ReminderUnit.Minutes
|
||||||
|
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||||
|
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||||
|
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||||
|
else -> minutes.toString() to ReminderUnit.Minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
|
* never drifts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
|
||||||
|
* form (start/end times) and Settings (the all-day reminder fire time).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimePickerAlert(
|
||||||
|
initial: LocalTime,
|
||||||
|
onConfirm: (LocalTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||||
|
*
|
||||||
|
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||||
|
* the *app's* context locale — and this app applies a per-app language
|
||||||
|
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||||
|
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||||
|
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||||
|
* (Resources.getSystem), not the app's.
|
||||||
|
*/
|
||||||
|
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||||
|
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||||
|
"24" -> true
|
||||||
|
"12" -> false
|
||||||
|
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||||
|
else -> {
|
||||||
|
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||||
|
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -96,6 +95,7 @@ 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 de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -684,26 +684,7 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
|||||||
|
|
||||||
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLeadText(reminder: Reminder): String {
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
val minutes = reminder.minutes
|
|
||||||
return when {
|
|
||||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 -> {
|
|
||||||
val weeks = minutes / 10_080
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
|
||||||
}
|
|
||||||
minutes % 1_440 == 0 -> {
|
|
||||||
val days = minutes / 1_440
|
|
||||||
pluralStringResource(R.plurals.reminder_days, days, days)
|
|
||||||
}
|
|
||||||
minutes % 60 == 0 -> {
|
|
||||||
val hours = minutes / 60
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
|
||||||
}
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
|
|||||||
@@ -68,10 +68,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -86,7 +84,6 @@ import androidx.compose.ui.graphics.SolidColor
|
|||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -112,10 +109,17 @@ import de.jeanlucmakiola.calendula.domain.toRRule
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -916,14 +920,7 @@ private fun FieldPickerDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
|
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
|
||||||
|
|
||||||
private enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder picker, two steps: the common lead times as a tappable list
|
* Reminder picker, two steps: the common lead times as a tappable list
|
||||||
@@ -1245,84 +1242,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
|
|||||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
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) {
|
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||||
@@ -1377,13 +1296,6 @@ private fun AddReminderChip(onClick: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
|
||||||
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
|
||||||
ReminderUnit.Days -> R.string.reminder_unit_days
|
|
||||||
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
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
|
||||||
@@ -1517,16 +1429,7 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = when {
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
minutes <= 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
|
||||||
minutes % 1_440 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
|
||||||
minutes % 60 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's DetailCard: tonal container,
|
* One info card mirroring the detail screen's DetailCard: tonal container,
|
||||||
@@ -1687,31 +1590,6 @@ private fun ScheduleRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TimePickerAlert(
|
|
||||||
initial: LocalTime,
|
|
||||||
onConfirm: (LocalTime) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val state = rememberTimePickerState(
|
|
||||||
initialHour = initial.hour,
|
|
||||||
initialMinute = initial.minute,
|
|
||||||
)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
text = { TimePicker(state = state) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPickerDialog(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -71,6 +73,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Set while the form edits an existing event instead of composing a new one.
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
private val _loadFailed = MutableStateFlow(false)
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
// True once the user has hand-edited the reminders on a new event, which
|
||||||
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
|
// their choice. Reset with the form.
|
||||||
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
@@ -100,6 +106,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val editTarget: EditTarget?,
|
val editTarget: EditTarget?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val timed: Int?,
|
||||||
|
val allDay: Int?,
|
||||||
|
val timedOverrides: Map<Long, Int?>,
|
||||||
|
val allDayOverrides: Map<Long, Int?>,
|
||||||
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
@@ -194,6 +207,48 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new event's reminders from the settings default — the all-day
|
||||||
|
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||||
|
* override or the global timed default. No-op while editing an existing event
|
||||||
|
* or once the user has hand-edited the reminders, so the auto-default never
|
||||||
|
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
|
||||||
|
* calendar switch; null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
val form = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
|
val default = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
|
val reminders = listOfNotNull(default)
|
||||||
|
_form.value = form.copy(reminders = reminders)
|
||||||
|
// Surface the section so an auto-applied default is visible and
|
||||||
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
if (reminders.isNotEmpty()) {
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +284,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = emptySet()
|
_revealed.value = emptySet()
|
||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
|
_remindersTouched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -239,14 +295,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) {
|
||||||
|
update { it.copy(isAllDay = value) }
|
||||||
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
* The event falls back to the new calendar's colour until re-picked.
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
fun setCalendar(id: Long) {
|
||||||
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
|
// the user has already hand-edited it (guarded inside).
|
||||||
|
applyDefaultReminder(id)
|
||||||
|
}
|
||||||
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) }
|
||||||
|
|
||||||
@@ -262,12 +328,14 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) = update {
|
fun addReminder(minutes: Int) {
|
||||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReminder(minutes: Int) = update {
|
fun removeReminder(minutes: Int) {
|
||||||
it.copy(reminders = it.reminders - minutes)
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = it.reminders - minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
|||||||
@@ -1,36 +1,78 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** UI-facing language choice. AUTO follows the system languages. */
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
* platform per-app-languages API; below that the appcompat backport persists
|
*
|
||||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
* current value for the dropdown.
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
*/
|
*/
|
||||||
object AppLanguage {
|
object AppLanguage {
|
||||||
|
|
||||||
fun current(): LanguagePref {
|
/**
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
if (locales.isEmpty) return LanguagePref.AUTO
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
return when (locales[0]?.language) {
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
"de" -> LanguagePref.GERMAN
|
* system-default entry rather than crashing).
|
||||||
"en" -> LanguagePref.ENGLISH
|
*/
|
||||||
else -> LanguagePref.AUTO
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
}
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
|
}
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apply(pref: LanguagePref) {
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
val locales = when (pref) {
|
fun currentTag(): String? {
|
||||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
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.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -31,20 +34,22 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
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
|
||||||
@@ -63,17 +68,28 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||||
@@ -188,11 +204,15 @@ private fun SettingsHub(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
summary = languageLabel(current),
|
summary = languageLabel(current),
|
||||||
@@ -202,9 +222,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
options = LanguagePref.entries,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -378,7 +398,7 @@ private fun AppearanceScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
@@ -388,7 +408,7 @@ private fun AppearanceScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WeekStartPref.entries,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
@@ -482,6 +502,12 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -489,13 +515,273 @@ private fun NotificationsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
summary = stringResource(R.string.settings_reminders_hint),
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
},
|
},
|
||||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAllDayReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_allday_reminder_time),
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_time_hint,
|
||||||
|
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: each writable calendar may keep, drop, or
|
||||||
|
// replace the global default — separately for timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list of three
|
||||||
|
// (the card + the timed and all-day override rows).
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery reliability: Android's battery optimisation can delay or drop
|
||||||
|
// the calendar provider's reminder broadcast. A soft, optional exemption
|
||||||
|
// (system-settings deep-link, no special permission) improves on-time
|
||||||
|
// delivery; shown as live status, reversible by the user at any time.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reliable_delivery),
|
||||||
|
summary = if (batteryExempt) {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
|
},
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDefaultReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
presets = REMINDER_PRESETS,
|
||||||
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||||
|
onDismiss = { showDefaultReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
||||||
|
onDismiss = { showAllDayReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminderTime) {
|
||||||
|
TimePickerAlert(
|
||||||
|
initial = LocalTime(
|
||||||
|
state.allDayReminderTimeMinutes / 60,
|
||||||
|
state.allDayReminderTimeMinutes % 60,
|
||||||
|
),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
||||||
|
showAllDayReminderTime = false
|
||||||
|
},
|
||||||
|
onDismiss = { showAllDayReminderTime = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
overrideDialog?.let { target ->
|
||||||
|
val map = if (target.isAllDay) {
|
||||||
|
state.perCalendarAllDayReminderOverride
|
||||||
|
} else {
|
||||||
|
state.perCalendarReminderOverride
|
||||||
|
}
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(
|
||||||
|
if (target.isAllDay) {
|
||||||
|
R.string.settings_default_reminder_allday
|
||||||
|
} else {
|
||||||
|
R.string.settings_default_reminder
|
||||||
|
},
|
||||||
|
),
|
||||||
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
|
selected = map.choiceFor(target.calendarId),
|
||||||
|
allowInherit = true,
|
||||||
|
onSelect = {
|
||||||
|
if (target.isAllDay) {
|
||||||
|
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||||
|
} else {
|
||||||
|
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { overrideDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
|
/** A global default (null = none) as a picker choice for selection highlighting. */
|
||||||
|
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||||
|
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||||
|
|
||||||
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
|
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||||
|
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
|
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||||
|
* settings without needing to leave and re-enter the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
exempt = isIgnoringBatteryOptimizations(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
return exempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return power.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the user straight to Calendula's exemption: the direct
|
||||||
|
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||||
|
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||||
|
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||||
|
*/
|
||||||
|
private fun openBatteryOptimizationSettings(context: Context) {
|
||||||
|
val direct = Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
"package:${context.packageName}".toUri(),
|
||||||
|
)
|
||||||
|
if (runCatching { context.startActivity(direct) }.isFailure) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||||
|
* before midnight" reminder on an all-day event is rarely what's wanted.
|
||||||
|
*/
|
||||||
|
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||||
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
|
val time = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
|
||||||
|
set(Calendar.MINUTE, minutesOfDay % 60)
|
||||||
|
}.time
|
||||||
|
return DateFormat.getTimeFormat(context).format(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||||
|
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
|
this[calendarId] == null -> CalendarReminderOverride.None
|
||||||
|
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||||
|
@Composable
|
||||||
|
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||||
|
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||||
|
|
||||||
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun calendarOverrideSummary(
|
||||||
|
choice: CalendarReminderOverride,
|
||||||
|
globalDefault: Int?,
|
||||||
|
): String = when (choice) {
|
||||||
|
CalendarReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -531,38 +817,6 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
|
||||||
@Composable
|
|
||||||
private fun <T> OptionPickerDialog(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
options.forEach { option ->
|
|
||||||
OptionCard(
|
|
||||||
label = label(option),
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
selected = option == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUrl(context: Context, url: String) {
|
private fun openUrl(context: Context, url: String) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
@@ -598,10 +852,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
private fun languageLabel(tag: String?): String =
|
||||||
when (pref) {
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
|
||||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
|
||||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,25 @@ data class SettingsUiState(
|
|||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||||
|
* null = no default reminder. Per-calendar overrides take precedence.
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Int? = null,
|
||||||
|
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||||
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
* reminder); absent = inherit the global default.
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
|
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Whether the event-colour picker is offered on calendars that publish no
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import android.os.Build
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
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.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -18,14 +24,20 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
repository: CalendarRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(
|
combine(
|
||||||
// combine() only types up to five flows, so the sixth pref folds
|
// combine() types up to five flows, so the prefs split into two
|
||||||
// into the assembled state in an outer combine.
|
// groups that fold together in the outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -42,15 +54,50 @@ class SettingsViewModel @Inject constructor(
|
|||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
combine(
|
||||||
prefs.allowColorOnUnsupportedCalendars,
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
) { base, allowColor ->
|
prefs.defaultReminderMinutes,
|
||||||
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
prefs.defaultAllDayReminderMinutes,
|
||||||
|
prefs.allDayReminderTimeMinutes,
|
||||||
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
||||||
|
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.perCalendarReminderOverride,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
writableCalendars,
|
||||||
|
) { overrides, allDayOverrides, calendars ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
|
},
|
||||||
|
) { base, defaults, overrides ->
|
||||||
|
base.copy(
|
||||||
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
perCalendarReminderOverride = overrides.timed,
|
||||||
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
|
writableCalendars = overrides.calendars,
|
||||||
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val allowColor: Boolean,
|
||||||
|
val defaultReminder: Int?,
|
||||||
|
val allDayReminder: Int?,
|
||||||
|
val allDayReminderTime: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderOverrides(
|
||||||
|
val timed: Map<Long, Int?>,
|
||||||
|
val allDay: Map<Long, Int?>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -71,6 +118,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,14 +248,26 @@
|
|||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</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_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
|
<string name="settings_default_reminder">Standard-Erinnerung</string>
|
||||||
|
<string name="settings_default_reminder_allday">Ganztägige Termine</string>
|
||||||
|
<string name="settings_allday_reminder_time">Uhrzeit für ganztägige Erinnerungen</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Erinnerungen für ganztägige Termine werden um %1$s ausgelöst</string>
|
||||||
|
<string name="reminder_none">Keine</string>
|
||||||
|
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||||
|
<string name="reminder_custom_amount">Anzahl</string>
|
||||||
|
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Übernehmen</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||||
<string name="settings_section_calendars">Kalender</string>
|
<string name="settings_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</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>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
|
||||||
<string name="settings_language_english">English</string>
|
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
|
|||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
|
||||||
|
(#101316) so activity recreation (e.g. language switch) doesn't flash a
|
||||||
|
lighter grey. See values/colors.xml. -->
|
||||||
|
<color name="window_background">#FF101316</color>
|
||||||
|
</resources>
|
||||||
@@ -3,4 +3,8 @@
|
|||||||
<color name="seed">#FF5C6B7A</color>
|
<color name="seed">#FF5C6B7A</color>
|
||||||
<!-- Adaptive icon background -->
|
<!-- Adaptive icon background -->
|
||||||
<color name="ic_launcher_background">#FF5C6B7A</color>
|
<color name="ic_launcher_background">#FF5C6B7A</color>
|
||||||
|
<!-- Window backdrop shown during activity recreation (e.g. on a language
|
||||||
|
switch). Matches the Compose light scheme background/surface so the
|
||||||
|
recreation is seamless; overridden for dark in values-night. -->
|
||||||
|
<color name="window_background">#FFFBFCFE</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -245,14 +245,26 @@
|
|||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</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_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
|
<string name="settings_default_reminder">Default reminder</string>
|
||||||
|
<string name="settings_default_reminder_allday">All-day events</string>
|
||||||
|
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
||||||
|
<string name="reminder_none">None</string>
|
||||||
|
<string name="reminder_use_default">Use default reminder</string>
|
||||||
|
<string name="reminder_custom_amount">Amount</string>
|
||||||
|
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Set</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||||
<string name="settings_section_calendars">Calendars</string>
|
<string name="settings_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage calendars</string>
|
<string name="settings_manage_calendars">Manage calendars</string>
|
||||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</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>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
|
||||||
<string name="settings_language_english">English</string>
|
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
|
<!-- AppCompat (DayNight) parent so MainActivity can be an AppCompatActivity,
|
||||||
|
which is required for AppCompatDelegate.setApplicationLocales (the in-app
|
||||||
|
language picker) to sync to the system. Actual colours are driven by the
|
||||||
|
Compose theme; this is essentially the launch/backdrop theme. -->
|
||||||
|
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
|||||||
13
app/src/main/res/xml/locales_config.xml
Normal file
13
app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The languages Calendula ships translations for. This is the single source of
|
||||||
|
truth: each entry must have a matching res/values-<tag>/strings.xml, and is
|
||||||
|
surfaced automatically in both the in-app language picker (parsed at runtime
|
||||||
|
by AppLanguage) and the system per-app language settings (Android 13+, via
|
||||||
|
android:localeConfig in the manifest). To add a community translation, drop
|
||||||
|
in the values-<tag> folder and add one <locale> line here.
|
||||||
|
-->
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en" />
|
||||||
|
<locale android:name="de" />
|
||||||
|
</locale-config>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
class AllDayReminderEncodingTest {
|
||||||
|
|
||||||
|
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
|
||||||
|
private val nineAm = 9 * 60
|
||||||
|
private val summer = LocalDate.of(2026, 6, 20) // CEST, UTC+2
|
||||||
|
private val winter = LocalDate.of(2026, 1, 20) // CET, UTC+1
|
||||||
|
|
||||||
|
/** The instant the provider would actually fire: DTSTART(UTC midnight) − raw. */
|
||||||
|
private fun actualFire(rawMinutes: Int, startDate: LocalDate): Long =
|
||||||
|
startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() -
|
||||||
|
rawMinutes * 60_000L
|
||||||
|
|
||||||
|
/** The wall-clock instant we intend: [time] local, [daysBefore] days before [startDate]. */
|
||||||
|
private fun intendedFire(startDate: LocalDate, daysBefore: Int, timeMinutes: Int): Long =
|
||||||
|
startDate.minusDays(daysBefore.toLong())
|
||||||
|
.atTime(LocalTime.of(timeMinutes / 60, timeMinutes % 60))
|
||||||
|
.atZone(berlin).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one day before at 9am fires at 9am local the day before (summer)`() {
|
||||||
|
val raw = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
||||||
|
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 1, nineAm))
|
||||||
|
// 09:00 CEST is 07:00Z, 7h later than the bare midnight offset: 1440 − 420.
|
||||||
|
assertThat(raw).isEqualTo(1_020)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one day before at 9am fires at 9am local the day before (winter)`() {
|
||||||
|
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
|
||||||
|
assertThat(actualFire(raw, winter)).isEqualTo(intendedFire(winter, 1, nineAm))
|
||||||
|
// 09:00 CET is 08:00Z, 8h later than midnight: 1440 − 480.
|
||||||
|
assertThat(raw).isEqualTo(960)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `at time of event encodes a negative offset firing 9am on the day (summer)`() {
|
||||||
|
val raw = toProviderAllDayMinutes(0, summer, berlin, nineAm)
|
||||||
|
assertThat(raw).isLessThan(0) // fires after DTSTART; must not be clamped
|
||||||
|
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 0, nineAm))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips for whole-day lead times across both seasons`() {
|
||||||
|
for (date in listOf(summer, winter)) {
|
||||||
|
for (time in listOf(0, nineAm, 20 * 60)) {
|
||||||
|
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
|
||||||
|
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
|
||||||
|
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
|
||||||
|
// Reminders written before this feature stored raw N*1440 (fired at UTC
|
||||||
|
// midnight). They must still read back as "N days before".
|
||||||
|
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
|
||||||
|
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
|
||||||
|
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decoding is independent of the time-of-day used to encode`() {
|
||||||
|
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
||||||
|
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
|
||||||
|
assertThat(atNine).isNotEqualTo(atEight)
|
||||||
|
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
|
||||||
|
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `negative semantic minutes (provider-default sentinel) pass through`() {
|
||||||
|
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||||
|
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||||
|
// offset tuned for a CET anchor fires an hour off once the series crosses
|
||||||
|
// into CEST. Bounded to ±1h; documented, not fixed.
|
||||||
|
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
|
||||||
|
val summerOccurrence = LocalDate.of(2026, 7, 20)
|
||||||
|
val fire = actualFire(raw, summerOccurrence).let(java.time.Instant::ofEpochMilli)
|
||||||
|
.atZone(berlin).toLocalTime()
|
||||||
|
assertThat(fire).isEqualTo(LocalTime.of(10, 0)) // 09:00 intended, +1h in CEST
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -28,6 +29,13 @@ class CalendarRepositoryImplTest {
|
|||||||
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||||
CalendarPrefs(newDataStore(tempDir))
|
CalendarPrefs(newDataStore(tempDir))
|
||||||
|
|
||||||
|
private fun newSettings(tempDir: Path): SettingsPrefs =
|
||||||
|
SettingsPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("repo_test_settings.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
PreferenceDataStoreFactory.create(
|
PreferenceDataStoreFactory.create(
|
||||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||||
@@ -53,7 +61,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
repo.calendars().test {
|
repo.calendars().test {
|
||||||
val first = awaitItem()
|
val first = awaitItem()
|
||||||
@@ -67,7 +75,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L))
|
calendarsResult = listOf(makeCal(1L))
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
repo.calendars().test {
|
repo.calendars().test {
|
||||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||||
@@ -91,7 +99,7 @@ class CalendarRepositoryImplTest {
|
|||||||
listOf(makeEvent(10L))
|
listOf(makeEvent(10L))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -107,7 +115,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -129,7 +137,7 @@ class CalendarRepositoryImplTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -149,7 +157,7 @@ class CalendarRepositoryImplTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -165,7 +173,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
@@ -179,12 +187,32 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.insertedForms).containsExactly(form)
|
assertThat(fake.insertedForms).containsExactly(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createEvent passes the configured all-day reminder time to the data source`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val settings = newSettings(tempDir)
|
||||||
|
settings.setAllDayReminderTimeMinutes(8 * 60) // 08:00
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), settings, Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.createEvent(form)
|
||||||
|
|
||||||
|
assertThat(fake.allDayReminderTimes).containsExactly(480)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("insert event")
|
writeError = WriteFailedException("insert event")
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
@@ -202,7 +230,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
@@ -221,7 +249,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("update event id=42")
|
writeError = WriteFailedException("update event id=42")
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
@@ -239,7 +267,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@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()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.deleteEvent(eventId = 42L)
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
|
||||||
@@ -250,7 +278,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
@@ -261,7 +289,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
@@ -273,7 +301,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val form = EventForm(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Moved",
|
title = "Moved",
|
||||||
@@ -291,7 +319,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Weekly",
|
title = "Weekly",
|
||||||
@@ -318,7 +346,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("delete event id=42")
|
writeError = WriteFailedException("delete event id=42")
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repo.deleteEvent(eventId = 42L)
|
repo.deleteEvent(eventId = 42L)
|
||||||
@@ -331,7 +359,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
|
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
val id = repo.createLocalCalendar(
|
val id = repo.createLocalCalendar(
|
||||||
displayName = "Home",
|
displayName = "Home",
|
||||||
@@ -348,7 +376,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.updateCalendar(
|
repo.updateCalendar(
|
||||||
id = 5L,
|
id = 5L,
|
||||||
@@ -365,7 +393,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.deleteCalendar(id = 7L)
|
repo.deleteCalendar(id = 7L)
|
||||||
|
|
||||||
@@ -377,7 +405,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("create local calendar 'Home'")
|
writeError = WriteFailedException("create local calendar 'Home'")
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
||||||
@@ -392,7 +420,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
eventDetailResult = { null }
|
eventDetailResult = { null }
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repo.eventDetail(eventId = 999L)
|
repo.eventDetail(eventId = 999L)
|
||||||
@@ -411,7 +439,7 @@ class CalendarRepositoryImplTest {
|
|||||||
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
assertThat(repo.eventColorPalette(7L))
|
assertThat(repo.eventColorPalette(7L))
|
||||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||||
|
|||||||
@@ -66,20 +66,36 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
deletedCalendarIds += id
|
deletedCalendarIds += id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||||
|
val allDayReminderTimes = mutableListOf<Int>()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
insertedForms += form
|
insertedForms += form
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
updatedEvents += Triple(eventId, original, updated)
|
updatedEvents += Triple(eventId, original, updated)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
updatedOccurrences += Triple(eventId, beginMillis, form)
|
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +104,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,118 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setDefaultReminderMinutes(30)
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
||||||
|
prefs.setDefaultReminderMinutes(null)
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `per-calendar override round-trips minutes, none, and inherit`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||||
|
|
||||||
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||||
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||||
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
|
assertThat(map).containsExactly(7L, 15, 9L, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit drops the override entirely (absent != null value).
|
||||||
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||||
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
|
assertThat(map).containsExactly(7L, 15)
|
||||||
|
assertThat(map.containsKey(9L)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||||
|
prefs.setDefaultAllDayReminderMinutes(1_440)
|
||||||
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
||||||
|
prefs.setDefaultAllDayReminderMinutes(null)
|
||||||
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||||
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
||||||
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||||
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
||||||
|
// Clearing the all-day override leaves the timed one untouched.
|
||||||
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||||
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||||
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||||
|
val timed = mapOf(7L to 15, 9L to null)
|
||||||
|
val allDay = mapOf(7L to 2_880)
|
||||||
|
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||||
|
timedGlobal = 30,
|
||||||
|
allDayGlobal = 1_440,
|
||||||
|
timedOverrides = timed,
|
||||||
|
allDayOverrides = allDay,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
)
|
||||||
|
// Timed: minutes override, explicit none, inherit global, no calendar.
|
||||||
|
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
||||||
|
assertThat(resolve(9L, isAllDay = false)).isNull()
|
||||||
|
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
||||||
|
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
||||||
|
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||||
|
// override (cal 9) does not bleed into all-day.
|
||||||
|
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
||||||
|
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
||||||
|
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day reminder time defaults to 9am`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(540)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day reminder time round-trips and clamps to a valid minute-of-day`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setAllDayReminderTimeMinutes(8 * 60 + 30)
|
||||||
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(510)
|
||||||
|
prefs.setAllDayReminderTimeMinutes(5_000)
|
||||||
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(1_439)
|
||||||
|
prefs.setAllDayReminderTimeMinutes(-10)
|
||||||
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
94
scripts/check_translations.py
Executable file
94
scripts/check_translations.py
Executable file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate Android translation resources against the base strings.xml.
|
||||||
|
|
||||||
|
Community translations live in ``app/src/main/res/values-<locale>/strings.xml``
|
||||||
|
and are produced via Weblate. This guard keeps incoming translation PRs honest:
|
||||||
|
|
||||||
|
* every translation file must be well-formed XML;
|
||||||
|
* a translation must not define keys absent from the base — those are stale
|
||||||
|
keys left behind after a rename/removal upstream;
|
||||||
|
* a translation must not translate strings marked ``translatable="false"`` in
|
||||||
|
the base (URLs, IDs and the like).
|
||||||
|
|
||||||
|
Missing keys are *allowed* and only reported as coverage: a missing string
|
||||||
|
falls back to the English base at runtime, so partial translations are fine
|
||||||
|
(this mirrors the lint config, which downgrades ``MissingTranslation``).
|
||||||
|
|
||||||
|
Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
|
||||||
|
Actions ``::error`` annotations so they surface inline on the PR.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RES_DIR = Path("app/src/main/res")
|
||||||
|
BASE = RES_DIR / "values" / "strings.xml"
|
||||||
|
RESOURCE_TAGS = ("string", "plurals", "string-array")
|
||||||
|
|
||||||
|
|
||||||
|
def entries(path: Path) -> dict[str, bool]:
|
||||||
|
"""Map resource name -> is-translatable for every entry in ``path``."""
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
return {
|
||||||
|
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
||||||
|
for el in root
|
||||||
|
if el.tag in RESOURCE_TAGS and "name" in el.attrib
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not BASE.exists():
|
||||||
|
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
base = entries(BASE)
|
||||||
|
base_keys = set(base)
|
||||||
|
nontranslatable = {name for name, ok in base.items() if not ok}
|
||||||
|
translatable_total = len(base_keys - nontranslatable)
|
||||||
|
|
||||||
|
files = sorted(RES_DIR.glob("values-*/strings.xml"))
|
||||||
|
if not files:
|
||||||
|
print("No translation files found (values-*/strings.xml).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
errors = 0
|
||||||
|
for path in files:
|
||||||
|
locale = path.parent.name[len("values-"):]
|
||||||
|
try:
|
||||||
|
translated = entries(path)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
keys = set(translated)
|
||||||
|
stale = sorted(keys - base_keys)
|
||||||
|
translated_fixed = sorted(keys & nontranslatable)
|
||||||
|
missing = base_keys - nontranslatable - keys
|
||||||
|
|
||||||
|
for name in stale:
|
||||||
|
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
|
||||||
|
errors += 1
|
||||||
|
for name in translated_fixed:
|
||||||
|
print(
|
||||||
|
f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
|
||||||
|
"in the base and must not be translated"
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
covered = translatable_total - len(missing)
|
||||||
|
pct = covered * 100 // translatable_total if translatable_total else 100
|
||||||
|
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
|
||||||
|
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\n{errors} translation error(s) found.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("\nAll translation files are consistent with the base.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user