Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df | |||
| 5e6defd4c7 | |||
| 6e7ae3e60d | |||
| b0b30eef91 | |||
| 8b25c9be39 | |||
| 2943f3945d |
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
|
||||
@@ -169,7 +169,9 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan
|
||||
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||
5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra
|
||||
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||
|
||||
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||
|
||||
@@ -223,28 +225,42 @@ Out of scope (no new settings *features* here) — this is a structure + style
|
||||
pass on the existing controls; new toggles ride in with their own features.
|
||||
|
||||
**Tier 2 — navigation & daily-driver completeness**
|
||||
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
|
||||
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
|
||||
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||
|
||||
**Tier 3 — platform reach (depends on Tier 2)**
|
||||
7. Home-screen widget — built on the agenda data source from #6
|
||||
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
|
||||
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
|
||||
|
||||
**Tier 4 — interop & bigger-ticket**
|
||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
||||
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
||||
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||
9. **Reminders — defaults + delivery reliability** *(next)* — global default
|
||||
reminder **+ per-calendar override**, bundled with exact-alarm / battery
|
||||
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)**
|
||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||
|
||||
**Bottom — deprioritised, not important**
|
||||
- Duplicate event (detail action → prefilled create form) — moved here
|
||||
2026-06-17; cheap but low value, pick up only if asked
|
||||
|
||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||
|
||||
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
||||
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
||||
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||
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
|
||||
|
||||
@@ -254,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
|
||||
natural data source for a future widget)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -291,12 +312,103 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
||||
go/no-go gate as the OSM/INTERNET item below.
|
||||
- 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)*
|
||||
- **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
|
||||
exact-alarm / WorkManager decision)
|
||||
- Settings default reminder applied to new events
|
||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||
|
||||
## Sharing & interop
|
||||
|
||||
@@ -306,8 +418,18 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
||||
|
||||
## Platform & launchers
|
||||
|
||||
- Home-screen widget *(was v3.0)*
|
||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||
- ~~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)*
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
|
||||
backlog is now organised by theme in `ROADMAP.md`.
|
||||
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
||||
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
||||
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
||||
|
||||
## Progress
|
||||
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
|
||||
list of the next month of events grouped under day headers, with refresh and
|
||||
"New event" buttons — and a month-grid widget showing the full month with
|
||||
today highlighted, connected multi-day event bars, and prev/next/today
|
||||
navigation. Both reuse the in-app grouping and layout so they match the app
|
||||
exactly, respect your hidden-calendar choices, and refresh automatically when
|
||||
the calendar changes or the day rolls over. Tapping a day opens that day;
|
||||
tapping an event opens its details
|
||||
- App shortcut: long-press the Calendula icon for a "New event" action that
|
||||
jumps straight into the create-event form
|
||||
- Agenda view — a fourth top-level view alongside Month/Week/Day: a
|
||||
forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
|
||||
headers, reachable from the view switcher
|
||||
- Jump to date — a "Jump to date" row in the navigation drawer opens a date
|
||||
picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
|
||||
|
||||
## [2.4.0] — 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||
// (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.
|
||||
versionCode = 20400
|
||||
versionName = "2.4.0"
|
||||
versionCode = 20600
|
||||
versionName = "2.6.0"
|
||||
|
||||
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 {
|
||||
unitTests {
|
||||
all { it.useJUnitPlatform() }
|
||||
@@ -113,6 +122,9 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<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
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
@@ -25,6 +32,7 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Calendula"
|
||||
@@ -38,6 +46,11 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
@@ -54,6 +67,51 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
android:label="@string/widget_agenda_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_agenda" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.month.MonthWidgetReceiver"
|
||||
android:label="@string/widget_month_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_month" />
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||
and the system broadcasts the date/time ones at midnight / clock
|
||||
changes so "today" highlighting rolls over. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -18,21 +18,28 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A navigation a home-screen widget asked for (open a date / start a
|
||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
@@ -51,6 +58,8 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +68,18 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||
// can't carry typed extras, so the action alone signals create-on-today.
|
||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||
@@ -75,6 +96,12 @@ class MainActivity : ComponentActivity() {
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||
|
||||
/**
|
||||
* Intent opening the detail screen of one occurrence (reminder
|
||||
@@ -93,5 +120,22 @@ class MainActivity : ComponentActivity() {
|
||||
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://create/$date".toUri()
|
||||
putExtra(EXTRA_CREATE, true)
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
@@ -60,24 +61,40 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
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
|
||||
* match [updated]. [original] is the form as it was prefilled from the
|
||||
* event, so only fields the user actually changed are written and the
|
||||
* reminder rows can be diffed instead of wiped.
|
||||
* [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
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `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
|
||||
@@ -92,6 +109,7 @@ interface CalendarDataSource {
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long
|
||||
|
||||
/**
|
||||
@@ -265,7 +283,33 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
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 values = ContentValues().apply {
|
||||
put(
|
||||
@@ -303,7 +347,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Best effort (spec §8): the event exists at this point — a reminder
|
||||
// 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 {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
@@ -316,7 +361,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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(
|
||||
original = original,
|
||||
updated = updated,
|
||||
@@ -332,13 +382,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||
}
|
||||
// Untouched reminder sets are left alone so unrelated edits can't
|
||||
// disturb provider rows the form never knew about.
|
||||
// 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()) {
|
||||
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.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
@@ -352,7 +408,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val exceptionId = ContentUris.parseId(uri)
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, form.reminders)
|
||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
@@ -361,16 +417,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on (or with no rule to split) this is
|
||||
// just a series update.
|
||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||
updateEvent(eventId, original, updated)
|
||||
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||
return eventId
|
||||
}
|
||||
// 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)
|
||||
return newEventId
|
||||
}
|
||||
@@ -456,9 +513,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
||||
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||
* 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>) {
|
||||
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.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -28,9 +30,14 @@ import javax.inject.Singleton
|
||||
class CalendarRepositoryImpl @Inject constructor(
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : 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>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
@@ -93,7 +100,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
dataSource.insertEvent(form)
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun updateEvent(
|
||||
@@ -101,7 +108,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = withContext(io) {
|
||||
dataSource.updateEvent(eventId, original, updated)
|
||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||
@@ -113,7 +120,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
form: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun updateEventFromOccurrence(
|
||||
@@ -122,7 +129,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): 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) {
|
||||
|
||||
@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
private const val TAG = "EventDetailMapper"
|
||||
|
||||
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||
val instance = EventInstance(
|
||||
instanceId = eventId,
|
||||
eventId = eventId,
|
||||
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
title = title,
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
||||
isAllDay = isAllDay,
|
||||
color = color,
|
||||
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
|
||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||
attendees = attendees,
|
||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||
reminders = reminders,
|
||||
reminders = displayReminders,
|
||||
status = status,
|
||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||
// 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.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -127,6 +128,97 @@ class SettingsPrefs @Inject constructor(
|
||||
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) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -143,10 +235,90 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
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 =
|
||||
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 =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -26,6 +27,9 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
@@ -42,6 +46,8 @@ fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
requestedDetailKey: LongArray? = null,
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
@@ -115,6 +121,28 @@ fun CalendarHost(
|
||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
pendingDayIso = req.dateIso
|
||||
view = CalendarView.Day
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
val iso = req.dateIso ?: Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||
heldCreateIso = iso
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
@@ -141,6 +169,13 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer the live key; fall back to the held one only while sliding out.
|
||||
|
||||
@@ -25,6 +25,8 @@ fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
requestedDetailKey: LongArray? = null,
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -58,6 +60,8 @@ fun RootScreen(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
/**
|
||||
* A navigation a home-screen widget asked the app to perform when launched.
|
||||
* Parsed from the launch intent in MainActivity and consumed once by
|
||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||
* they are not modelled here).
|
||||
*/
|
||||
sealed interface WidgetNavRequest {
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
val date: LocalDate,
|
||||
/** Events on this day, all-day first then ascending by start time. */
|
||||
val events: List<EventInstance>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
* grouped by day (only days that actually have events appear).
|
||||
*/
|
||||
sealed interface AgendaUiState {
|
||||
data object Loading : AgendaUiState
|
||||
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||
data class Success(
|
||||
/** First day of the loaded window (today, or a jumped-to date). */
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
) : AgendaUiState
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How far ahead the agenda loads events from its anchor day. */
|
||||
internal const val AGENDA_WINDOW_DAYS = 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class AgendaViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val state: StateFlow<AgendaUiState> = _anchor
|
||||
.flatMapLatest { anchor ->
|
||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AgendaUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = anchor.atStartOfDayIn(zone)
|
||||
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */
|
||||
const val MILLIS_PER_DAY: Long = 86_400_000L
|
||||
|
||||
/**
|
||||
* The app's standard Material 3 date picker, opened on [initial] and reporting
|
||||
* the chosen day through [onConfirm]. Shared by the event form (start/end date,
|
||||
* RRULE until) and the drawer's jump-to-date action.
|
||||
*
|
||||
* DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
||||
* conversion zone-proof in both directions.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarDatePickerDialog(
|
||||
initial: LocalDate,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
state.selectedDateMillis?.let { millis ->
|
||||
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.dialog_ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
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.draw.clip
|
||||
@@ -32,23 +37,31 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Navigation drawer shared by every top-level calendar screen.
|
||||
*
|
||||
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
||||
* header, the View switcher as a grouped card (the active view highlighted),
|
||||
* the per-calendar visibility filter (M3) inline, and a pinned Settings row.
|
||||
* The "View" section mirrors the top-bar switcher pill — tapping a view here
|
||||
* selects it (and closes the drawer) rather than cycling. The host screen owns
|
||||
* the drawer state.
|
||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||
* The host screen owns the drawer state.
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarDrawer(
|
||||
currentView: CalendarView,
|
||||
currentDate: LocalDate,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
ModalDrawerSheet {
|
||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||
// and Settings all flow in a single scroll container.
|
||||
@@ -71,6 +84,15 @@ fun CalendarDrawer(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
@@ -87,6 +109,17 @@ fun CalendarDrawer(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
CalendarDatePickerDialog(
|
||||
initial = currentDate,
|
||||
onConfirm = {
|
||||
showDatePicker = false
|
||||
onJumpToDate(it)
|
||||
},
|
||||
onDismiss = { showDatePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Branded header: the app-icon chip beside the app name. */
|
||||
|
||||
@@ -5,17 +5,16 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||
import androidx.compose.material.icons.filled.ViewAgenda
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The top-level calendar views the user can switch between (spec M1).
|
||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
||||
*/
|
||||
/** The top-level calendar views the user can switch between (spec M1). */
|
||||
enum class CalendarView {
|
||||
Month,
|
||||
Week,
|
||||
Day,
|
||||
Agenda,
|
||||
}
|
||||
|
||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||
@@ -25,6 +24,7 @@ val CalendarView.labelRes: Int
|
||||
CalendarView.Month -> R.string.view_month
|
||||
CalendarView.Week -> R.string.view_week
|
||||
CalendarView.Day -> R.string.view_day
|
||||
CalendarView.Agenda -> R.string.view_agenda
|
||||
}
|
||||
|
||||
/** Leading icon for the view in the drawer's View section. */
|
||||
@@ -33,6 +33,7 @@ val CalendarView.icon: ImageVector
|
||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ val CalendarView.icon: ImageVector
|
||||
* through these in order.
|
||||
*/
|
||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
|
||||
@@ -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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -102,7 +104,19 @@ fun CollapsingScaffold(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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()
|
||||
// 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())
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,11 @@ fun DayScreen(
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < date) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -159,10 +164,15 @@ fun DayScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
jumpToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
|
||||
@@ -67,7 +67,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.ZoneId
|
||||
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". */
|
||||
@Composable
|
||||
private fun reminderLeadText(reminder: Reminder): String {
|
||||
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)
|
||||
}
|
||||
}
|
||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||
|
||||
/**
|
||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||
|
||||
@@ -51,8 +51,6 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -70,11 +68,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -89,7 +84,6 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -113,10 +107,19 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
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.InlineTextField
|
||||
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.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -786,12 +789,12 @@ private fun EventEditContent(
|
||||
}
|
||||
|
||||
when (picker) {
|
||||
PickerTarget.StartDate -> DatePickerAlert(
|
||||
PickerTarget.StartDate -> CalendarDatePickerDialog(
|
||||
initial = form.start.date,
|
||||
onConfirm = { viewModel.setStartDate(it); picker = null },
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
PickerTarget.EndDate -> DatePickerAlert(
|
||||
PickerTarget.EndDate -> CalendarDatePickerDialog(
|
||||
initial = form.end.date,
|
||||
onConfirm = { viewModel.setEndDate(it); picker = null },
|
||||
onDismiss = { picker = null },
|
||||
@@ -917,14 +920,7 @@ private fun FieldPickerDialog(
|
||||
}
|
||||
|
||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
|
||||
|
||||
private enum class ReminderUnit(val minutesFactor: Int) {
|
||||
Minutes(1),
|
||||
Hours(60),
|
||||
Days(1_440),
|
||||
Weeks(10_080),
|
||||
}
|
||||
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
|
||||
|
||||
/**
|
||||
* Reminder picker, two steps: the common lead times as a tappable list
|
||||
@@ -1178,7 +1174,7 @@ private fun RecurrencePickerDialog(
|
||||
)
|
||||
|
||||
if (showUntilPicker) {
|
||||
DatePickerAlert(
|
||||
CalendarDatePickerDialog(
|
||||
initial = untilDate ?: LocalDate.fromEpochDays(
|
||||
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
||||
),
|
||||
@@ -1246,84 +1242,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
|
||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||
|
||||
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
||||
@Composable
|
||||
private fun DialogAmountField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
) {
|
||||
// surfaceContainerHighest — the dialog itself sits on
|
||||
// surfaceContainerHigh, so anything lower vanishes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
||||
onValueChange(text)
|
||||
}
|
||||
},
|
||||
placeholder = placeholder,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
||||
@Composable
|
||||
private fun DialogUnitDropdown(
|
||||
label: String,
|
||||
entries: List<String>,
|
||||
onPick: (Int) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { open = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(
|
||||
start = 14.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry) },
|
||||
onClick = {
|
||||
onPick(index)
|
||||
open = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||
@@ -1378,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) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
@@ -1518,16 +1429,7 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
||||
|
||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||
@Composable
|
||||
private fun reminderLabel(minutes: Int): String = when {
|
||||
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)
|
||||
}
|
||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||
|
||||
/**
|
||||
* One info card mirroring the detail screen's DetailCard: tonal container,
|
||||
@@ -1688,62 +1590,6 @@ private fun ScheduleRow(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DatePickerAlert(
|
||||
initial: LocalDate,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
||||
// conversion zone-proof in both directions.
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
state.selectedDateMillis?.let { millis ->
|
||||
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.dialog_ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
private fun CalendarPickerDialog(
|
||||
calendars: List<CalendarSource>,
|
||||
@@ -1775,5 +1621,3 @@ private fun CalendarPickerDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MILLIS_PER_DAY = 86_400_000L
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
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.
|
||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||
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. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
@@ -100,6 +106,13 @@ class EventEditViewModel @Inject constructor(
|
||||
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(
|
||||
val writable: List<CalendarSource>,
|
||||
val lastUsed: Long?,
|
||||
@@ -194,6 +207,48 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
_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()
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
_remindersTouched.value = false
|
||||
}
|
||||
|
||||
/** 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 setLocation(value: String) = update { it.copy(location = 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
|
||||
* 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.
|
||||
*/
|
||||
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 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. */
|
||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||
|
||||
fun addReminder(minutes: Int) = update {
|
||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||
fun addReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
}
|
||||
|
||||
fun removeReminder(minutes: Int) = update {
|
||||
it.copy(reminders = it.reminders - minutes)
|
||||
fun removeReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/** Moving the start drags the end along, preserving the duration. */
|
||||
|
||||
@@ -124,6 +124,11 @@ fun MonthScreen(
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -132,10 +137,15 @@ fun MonthScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
jumpToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
|
||||
@@ -96,6 +96,11 @@ class MonthViewModel @Inject constructor(
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_month.value = YearMonth(date.year, date.month)
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
ym: YearMonth,
|
||||
weekStart: DayOfWeek,
|
||||
@@ -108,20 +113,25 @@ class MonthViewModel @Inject constructor(
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
today = todayDate,
|
||||
weeks = layoutMonth(ym, weekStart, instances),
|
||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the grid into week rows and resolve each row's events. An event is a
|
||||
* spanning bar when it's all-day or touches more than one of the row's days;
|
||||
* Split the month grid into week rows and resolve each row's events. An event is
|
||||
* a spanning bar when it's all-day or touches more than one of the row's days;
|
||||
* everything else is a single-day timed pill. Bars get lanes from the shared
|
||||
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
||||
*
|
||||
* Shared by the Month screen and the month home-screen widget so both lay out
|
||||
* spans, lanes and per-day counts identically.
|
||||
*/
|
||||
private fun layoutMonth(
|
||||
internal fun layoutMonthWeeks(
|
||||
ym: YearMonth,
|
||||
weekStart: DayOfWeek,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<MonthWeek> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
@@ -156,7 +166,6 @@ class MonthViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||
|
||||
@@ -1,36 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
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. */
|
||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
|
||||
/**
|
||||
* Per-app language via AppCompatDelegate. 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 dropdown.
|
||||
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||
*
|
||||
* That file is the single source of truth for which languages we ship: dropping
|
||||
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||
* 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 {
|
||||
|
||||
fun current(): LanguagePref {
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
if (locales.isEmpty) return LanguagePref.AUTO
|
||||
return when (locales[0]?.language) {
|
||||
"de" -> LanguagePref.GERMAN
|
||||
"en" -> LanguagePref.ENGLISH
|
||||
else -> LanguagePref.AUTO
|
||||
/**
|
||||
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||
* or malformed config yields an empty list (the picker then offers only the
|
||||
* system-default entry rather than crashing).
|
||||
*/
|
||||
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) {
|
||||
val locales = when (pref) {
|
||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||
fun currentTag(): String? {
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.pm.PackageManager
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -31,20 +34,22 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -63,17 +68,28 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
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.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.reminderLeadTimeLabel
|
||||
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. */
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||
@@ -188,11 +204,15 @@ private fun SettingsHub(
|
||||
|
||||
@Composable
|
||||
private fun LanguageRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||
// 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) }
|
||||
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
summary = languageLabel(current),
|
||||
@@ -202,9 +222,9 @@ private fun LanguageRow(position: Position) {
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
OptionPickerDialog(
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_language),
|
||||
options = LanguagePref.entries,
|
||||
options = options,
|
||||
selected = current,
|
||||
label = { languageLabel(it) },
|
||||
onSelect = {
|
||||
@@ -378,7 +398,7 @@ private fun AppearanceScreen(
|
||||
}
|
||||
|
||||
if (showTheme) {
|
||||
OptionPickerDialog(
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
options = ThemeMode.entries,
|
||||
selected = state.themeMode,
|
||||
@@ -388,7 +408,7 @@ private fun AppearanceScreen(
|
||||
)
|
||||
}
|
||||
if (showWeekStart) {
|
||||
OptionPickerDialog(
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
options = WeekStartPref.entries,
|
||||
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(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
onBack = onBack,
|
||||
@@ -489,13 +515,273 @@ private fun NotificationsScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||
},
|
||||
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) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
@@ -598,10 +852,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
||||
when (pref) {
|
||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
||||
},
|
||||
)
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,25 @@ data class SettingsUiState(
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
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
|
||||
* 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.viewModelScope
|
||||
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.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -18,14 +24,20 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
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> =
|
||||
combine(
|
||||
// combine() only types up to five flows, so the sixth pref folds
|
||||
// into the assembled state in an outer combine.
|
||||
// combine() types up to five flows, so the prefs split into two
|
||||
// groups that fold together in the outer combine.
|
||||
combine(
|
||||
prefs.themeMode,
|
||||
prefs.dynamicColor,
|
||||
@@ -42,15 +54,50 @@ class SettingsViewModel @Inject constructor(
|
||||
remindersEnabled = reminders,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
prefs.allowColorOnUnsupportedCalendars,
|
||||
) { base, allowColor ->
|
||||
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
||||
prefs.defaultReminderMinutes,
|
||||
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(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -71,6 +118,26 @@ class SettingsViewModel @Inject constructor(
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ fun WeekScreen(
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < weekStart) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -164,10 +169,15 @@ fun WeekScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
jumpToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
|
||||
@@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor(
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||
private const val AGENDA_WIDGET_DAYS = 30
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||
* prev/next navigation without re-querying on every arrow tap.
|
||||
*/
|
||||
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
||||
|
||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||
|
||||
internal fun today(zone: TimeZone): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
internal fun Context.hasCalendarPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Snapshot rendered by the agenda widget. */
|
||||
sealed interface AgendaWidgetData {
|
||||
/** Calendar permission not granted — the widget can't read events. */
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
* Source data for the month widget: a wide window of instances plus the
|
||||
* week-start preference and today. The widget computes each displayed month's
|
||||
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
||||
* so month navigation is pure recomposition — no reload, no flaky widget
|
||||
* session restart.
|
||||
*/
|
||||
sealed interface MonthWidgetSource {
|
||||
data object NeedsPermission : MonthWidgetSource
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
val weekStart: DayOfWeek,
|
||||
val instances: List<EventInstance>,
|
||||
) : MonthWidgetSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-lived cache of the wide month window. Month navigation re-runs
|
||||
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
||||
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
||||
* the same snapshot for every nearby month. Invalidated by
|
||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||
*/
|
||||
internal object MonthWidgetCache {
|
||||
@Volatile
|
||||
var data: MonthWidgetSource.Ready? = null
|
||||
}
|
||||
|
||||
internal fun invalidateMonthWidgetCache() {
|
||||
MonthWidgetCache.data = null
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||
*/
|
||||
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val repo = widgetEntryPoint().calendarRepository()
|
||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||
}
|
||||
|
||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
||||
val ep = widgetEntryPoint()
|
||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
val instances = ep.calendarRepository().instances(from..to).first()
|
||||
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
||||
.also { MonthWidgetCache.data = it }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
|
||||
/**
|
||||
* Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
|
||||
* is instantiated by the framework, not by Hilt, so it can't take constructor
|
||||
* injection. We instead reach the singleton graph through this entry point and
|
||||
* read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
|
||||
* data (hidden-calendar filtering, week-start preference, …) matches the app
|
||||
* one-to-one.
|
||||
*/
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface WidgetEntryPoint {
|
||||
fun calendarRepository(): CalendarRepository
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
}
|
||||
|
||||
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||
EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
|
||||
/**
|
||||
* Brand fallback for devices without Material You dynamic colour (API < 31).
|
||||
* Reuses the exact same hand-tuned schemes as the in-app theme
|
||||
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
|
||||
* device matches the app surface-for-surface.
|
||||
*/
|
||||
private val CalendulaGlanceColors = ColorProviders(
|
||||
light = CalendulaLightFallback,
|
||||
dark = CalendulaDarkFallback,
|
||||
)
|
||||
|
||||
/**
|
||||
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
|
||||
* Material You palette (so the widget matches the home screen / the app's
|
||||
* dynamic colour); below that it falls back to the brand scheme. Either way the
|
||||
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
|
||||
* a hardcoded colour — so it tracks light/dark automatically.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
GlanceTheme.colors
|
||||
} else {
|
||||
CalendulaGlanceColors
|
||||
}
|
||||
GlanceTheme(colors = colors, content = content)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||
* so it covers both the app's own writes and external sync.
|
||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||
* and the upcoming window roll over at midnight / on a clock change.
|
||||
*
|
||||
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||
*/
|
||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
val appContext = context.applicationContext
|
||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||
// month window so the widgets reload fresh. Month paging does NOT call
|
||||
// this, so arrow taps stay instant.
|
||||
invalidateMonthWidgetCache()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
provideContent {
|
||||
CalendulaGlanceTheme {
|
||||
AgendaWidgetBody(data = data, dark = dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val event: EventInstance) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
is AgendaWidgetData.Ready ->
|
||||
if (data.days.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
data.days.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaHeader() {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
contentDescription = context.getString(R.string.widget_refresh),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_add,
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
style = TextStyle(
|
||||
color = if (date == today) GlanceTheme.colors.primary
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.eventDetailIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetMessage(resId: Int) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(resId),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun zone(): TimeZone = systemZone()
|
||||
|
||||
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> context.getString(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val formatted = "$weekday, ${date.day} $monthName"
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
val t = instant.toLocalDateTime(zone()).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||
* `appwidget_info_agenda` provider metadata; delegates all rendering to
|
||||
* [AgendaWidget].
|
||||
*/
|
||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
private const val MAX_LANES = 3
|
||||
private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
|
||||
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
||||
private val EventInk = ColorProvider(Color(0xDE000000))
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||
val t = today(zone)
|
||||
return t.year * 12 + t.month.ordinal
|
||||
}
|
||||
|
||||
private fun yearMonthOf(index: Int): YearMonth =
|
||||
YearMonth(index / 12, Month(index % 12 + 1))
|
||||
|
||||
/**
|
||||
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||
* event bars and titled single-day pills (the in-app lane layout via
|
||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||
*
|
||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val source = context.loadMonthWidgetSource()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
provideContent {
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(source = source, dark = dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||
class ShiftMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||
}
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump the displayed month back to the current month. */
|
||||
class ResetMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula")
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
val zone = systemZone()
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||
val ym = yearMonthOf(index)
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
|
||||
MonthHeader(label = monthLabel(ym))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
weeks.forEach { week ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
currentMonth = ym.month,
|
||||
today = source.today,
|
||||
dark = dark,
|
||||
colW = colW,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||
),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||
* Computed outside the composable so the locale read stays observable-safe. */
|
||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
val locale = Locale.getDefault()
|
||||
return (0 until 7).map { i ->
|
||||
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
week: MonthWeek,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
dark: Boolean,
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Day numbers.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
OverflowRow(week = week, colW = colW)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
Box(
|
||||
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
||||
col = span.endCol + 1
|
||||
} else {
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, width = colW)
|
||||
} else {
|
||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||
}
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.cornerRadius(4.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||
Box(
|
||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = "+$hidden",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||
val occupied = week.spans
|
||||
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||
val timed = week.timedByDay[date].orEmpty()
|
||||
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionMessage() {
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_needs_permission),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthLabel(month: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${month.year}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
|
||||
*/
|
||||
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||
}
|
||||
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Launcher long-press shortcut icon ("New event"): a brand-coloured circle
|
||||
with a white calendar+plus glyph, as one scalable vector. Self-contained so
|
||||
it stays visible on any launcher surface (shortcut icons aren't tinted, so a
|
||||
bare white glyph would vanish on a light sheet). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#3B5364"
|
||||
android:pathData="M0,12 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0 Z" />
|
||||
<group
|
||||
android:scaleX="0.58"
|
||||
android:scaleY="0.58"
|
||||
android:translateX="5"
|
||||
android:translateY="5">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||
</group>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#4F7CAC" />
|
||||
<corners android:radius="3dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/widget_preview_primary" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_surface" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout). Not the
|
||||
runtime layout — the live widget is rendered by Glance. Colours are the
|
||||
brand light scheme so the picker preview reads on its light sheet. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_agenda_title"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/agenda_header_today"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
<ImageView
|
||||
android:layout_width="5dp"
|
||||
android:layout_height="34dp"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#4F7CAC" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Standup"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="09:00 – 09:30"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
<ImageView
|
||||
android:layout_width="5dp"
|
||||
android:layout_height="34dp"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#6FA37A" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Design review"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="11:30 – 12:30"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
<ImageView
|
||||
android:layout_width="5dp"
|
||||
android:layout_height="34dp"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#C58A56" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1:1 with Mara"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="14:00 – 14:30"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout). The live
|
||||
widget is rendered by Glance with real events; this only needs to read as
|
||||
"a month grid" in the picker. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="12dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="‹"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="16sp" />
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="June 2026"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="›"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Weekday header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Week 1: 1..7 (event bar on the 3rd) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Week 2: 8..14 (bars on 11,12,13) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Week 3: 15..21 (17 = today) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal">
|
||||
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center"
|
||||
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp"
|
||||
android:background="@drawable/preview_today_circle" />
|
||||
</LinearLayout>
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Week 4: 22..28 (bar on 24) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Week 5: 29, 30, then trailing July days greyed -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -196,8 +196,34 @@
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</string>
|
||||
<string name="view_day">Tag</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Ansicht</string>
|
||||
|
||||
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||
|
||||
<!-- Agenda-Ansicht -->
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
|
||||
<!-- Startbildschirm-Widgets -->
|
||||
<string name="widget_agenda_title">Anstehend</string>
|
||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||
<string name="widget_month_label">Calendula Monat</string>
|
||||
<string name="widget_refresh">Aktualisieren</string>
|
||||
<string name="widget_new_event">Neuer Termin</string>
|
||||
<string name="widget_needs_permission">Öffne Calendula, um Kalenderzugriff zu erlauben</string>
|
||||
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||
<string name="widget_next_month">Nächster Monat</string>
|
||||
<string name="widget_today">Heute</string>
|
||||
|
||||
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
@@ -222,14 +248,26 @@
|
||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||
<string name="settings_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_manage_calendars">Kalender verwalten</string>
|
||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||
<string name="settings_section_language">Sprache</string>
|
||||
<string name="settings_language">App-Sprache</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 -->
|
||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_preview_surface">@android:color/system_neutral1_900</color>
|
||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
||||
<color name="widget_preview_on_primary">@android:color/system_accent1_800</color>
|
||||
</resources>
|
||||
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>
|
||||
8
app/src/main/res/values-night/widget_preview_colors.xml
Normal file
8
app/src/main/res/values-night/widget_preview_colors.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_preview_surface">#101316</color>
|
||||
<color name="widget_preview_on_surface">#E1E3E6</color>
|
||||
<color name="widget_preview_variant">#A8ADB2</color>
|
||||
<color name="widget_preview_primary">#A3CBE2</color>
|
||||
<color name="widget_preview_on_primary">#003348</color>
|
||||
</resources>
|
||||
8
app/src/main/res/values-v31/widget_preview_colors.xml
Normal file
8
app/src/main/res/values-v31/widget_preview_colors.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_preview_surface">@android:color/system_neutral1_50</color>
|
||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_900</color>
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_700</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
||||
<color name="widget_preview_on_primary">@android:color/system_accent1_0</color>
|
||||
</resources>
|
||||
@@ -3,4 +3,8 @@
|
||||
<color name="seed">#FF5C6B7A</color>
|
||||
<!-- Adaptive icon background -->
|
||||
<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>
|
||||
|
||||
@@ -197,8 +197,30 @@
|
||||
<string name="view_month">Month</string>
|
||||
<string name="view_week">Week</string>
|
||||
<string name="view_day">Day</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">View</string>
|
||||
|
||||
<!-- Jump to date (drawer) -->
|
||||
<string name="drawer_jump_to_date">Jump to date</string>
|
||||
|
||||
<!-- Agenda view -->
|
||||
<string name="agenda_today_action">Today</string>
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
<string name="widget_month_label">Calendula month</string>
|
||||
<string name="widget_refresh">Refresh</string>
|
||||
<string name="widget_new_event">New event</string>
|
||||
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
|
||||
<string name="widget_prev_month">Previous month</string>
|
||||
<string name="widget_next_month">Next month</string>
|
||||
<string name="widget_today">Today</string>
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
|
||||
@@ -223,14 +245,26 @@
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_reminders">Event reminders</string>
|
||||
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||
<string name="settings_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_manage_calendars">Manage calendars</string>
|
||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</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 -->
|
||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
@@ -260,6 +294,10 @@
|
||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<!-- Launcher long-press shortcuts -->
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<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:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
|
||||
11
app/src/main/res/values/widget_preview_colors.xml
Normal file
11
app/src/main/res/values/widget_preview_colors.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Colours for the static widget-picker previews. Base = brand light fallback
|
||||
(API < 31). values-v31 maps them to the Material You dynamic palette so the
|
||||
preview matches the live Glance widget; -night holds the dark variants. -->
|
||||
<resources>
|
||||
<color name="widget_preview_surface">#FBFCFE</color>
|
||||
<color name="widget_preview_on_surface">#191C1F</color>
|
||||
<color name="widget_preview_variant">#6E7479</color>
|
||||
<color name="widget_preview_primary">#3B5364</color>
|
||||
<color name="widget_preview_on_primary">#FFFFFF</color>
|
||||
</resources>
|
||||
14
app/src/main/res/xml/appwidget_info_agenda.xml
Normal file
14
app/src/main/res/xml/appwidget_info_agenda.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="150dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="3"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_agenda_label"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_agenda"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
14
app/src/main/res/xml/appwidget_info_month.xml
Normal file
14
app/src/main/res/xml/appwidget_info_month.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="150dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_month_label"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_month"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
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>
|
||||
17
app/src/main/res/xml/shortcuts.xml
Normal file
17
app/src/main/res/xml/shortcuts.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Launcher long-press shortcuts. The intent fires a custom action that
|
||||
MainActivity (singleTop) consumes to open the create-event form on today;
|
||||
see MainActivity.ACTION_NEW_EVENT. -->
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shortcut
|
||||
android:shortcutId="new_event"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_new_event"
|
||||
android:shortcutShortLabel="@string/shortcut_new_event_short"
|
||||
android:shortcutLongLabel="@string/shortcut_new_event_long">
|
||||
<intent
|
||||
android:action="de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||
android:targetPackage="de.jeanlucmakiola.calendula"
|
||||
android:targetClass="de.jeanlucmakiola.calendula.MainActivity" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -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 com.google.common.truth.Truth.assertThat
|
||||
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.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -28,6 +29,13 @@ class CalendarRepositoryImplTest {
|
||||
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||
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> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||
@@ -53,7 +61,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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 {
|
||||
val first = awaitItem()
|
||||
@@ -67,7 +75,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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 {
|
||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||
@@ -91,7 +99,7 @@ class CalendarRepositoryImplTest {
|
||||
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)
|
||||
repo.instances(range).test {
|
||||
@@ -107,7 +115,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
repo.instances(range).test {
|
||||
@@ -165,7 +173,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
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(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
@@ -179,12 +187,32 @@ class CalendarRepositoryImplTest {
|
||||
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
|
||||
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
@@ -202,7 +230,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
@@ -221,7 +249,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
@@ -239,7 +267,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||
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)
|
||||
|
||||
@@ -250,7 +278,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
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)
|
||||
|
||||
@@ -261,7 +289,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
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)
|
||||
|
||||
@@ -273,7 +301,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Moved",
|
||||
@@ -291,7 +319,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Weekly",
|
||||
@@ -318,7 +346,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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 {
|
||||
repo.deleteEvent(eventId = 42L)
|
||||
@@ -331,7 +359,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
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(
|
||||
displayName = "Home",
|
||||
@@ -348,7 +376,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.updateCalendar(
|
||||
id = 5L,
|
||||
@@ -365,7 +393,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||
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)
|
||||
|
||||
@@ -377,7 +405,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
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 {
|
||||
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
||||
@@ -392,7 +420,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { null }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.eventDetail(eventId = 999L)
|
||||
@@ -411,7 +439,7 @@ class CalendarRepositoryImplTest {
|
||||
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))
|
||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||
|
||||
@@ -66,20 +66,36 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
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 }
|
||||
insertedForms += form
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
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 }
|
||||
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 }
|
||||
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
@@ -88,9 +104,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,118 @@ class SettingsPrefsTest {
|
||||
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
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||
|
||||
@@ -24,6 +24,8 @@ turbine = "1.2.0"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
@@ -79,6 +81,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
# Lifecycle compose (for collectAsStateWithLifecycle)
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
|
||||
|
||||
# Glance — Jetpack home-screen widgets (Compose-like RemoteViews)
|
||||
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
|
||||
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
|
||||
|
||||
# Android tests - GrantPermissionRule
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||
|
||||
|
||||
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