Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdedf47972 | |||
| a69be3da43 | |||
| 779fa1d480 | |||
| c59a071b82 | |||
| 285bfd90a7 | |||
| 9529f19c60 | |||
| 0013c9f3b1 | |||
| bd6ad4ae5f | |||
| 3697a58e5b | |||
| e290c92d78 | |||
| 9c4ebbc65a | |||
| c0d413ba11 | |||
| dca0245a42 | |||
| 024512959f | |||
| e78da3d7c1 | |||
| 2cb8b59fb7 | |||
| 7d36d22fd5 | |||
| adcbed6e02 | |||
| efa0abbaed | |||
| d3fbe28843 | |||
| 951fb640a6 | |||
| 94fa206e2e | |||
| 6a90bade8a | |||
| 0132201cf9 | |||
| b792ddc2f0 | |||
| 440fa57161 | |||
|
|
00b5aeaac7 | ||
| 2a2b919041 | |||
| 3ced240e23 | |||
| 035ac9b003 | |||
| c03389abe0 | |||
| 98f8433156 | |||
| 8fbbab30e2 | |||
| ef0a4b0568 | |||
| 43f12812b6 | |||
| 2400d5482c | |||
| 4d54501ed4 | |||
| 748df761bf | |||
| d13f2f07a5 | |||
| 7abb2e6ab4 | |||
| fb003d8806 | |||
| 40b531fa52 | |||
| 0e4c47febe |
@@ -6,7 +6,11 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '**'
|
- '**'
|
||||||
pull_request:
|
|
||||||
|
# Cancel superseded runs on the same branch.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -26,30 +30,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
|
# (~300 MB) which the build never uses.
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SUDO=""
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y jq
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
$SUDO apk add --no-cache jq
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
- name: Setup Gradle cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -63,16 +62,19 @@ jobs:
|
|||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variant only)
|
||||||
run: ./gradlew lintDebug --no-daemon
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -24,16 +24,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
@@ -42,10 +59,10 @@ jobs:
|
|||||||
# any tag-resolved drift (e.g. version code substitution issues).
|
# any tag-resolved drift (e.g. version code substitution issues).
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK (sanity)
|
- name: Assemble debug APK (sanity)
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: ci
|
needs: ci
|
||||||
@@ -65,16 +82,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Install jq
|
- name: Install jq
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -121,7 +155,7 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: ./gradlew assembleRelease --no-daemon
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
|||||||
### Active (V1)
|
### Active (V1)
|
||||||
|
|
||||||
- [x] Foundation & CI infrastructure
|
- [x] Foundation & CI infrastructure
|
||||||
- [ ] Data Layer over `CalendarContract`
|
- [x] Data Layer over `CalendarContract`
|
||||||
- [ ] Permission flow (`READ_CALENDAR`)
|
- [x] Permission flow (`READ_CALENDAR`)
|
||||||
- [ ] Month view (S1)
|
- [ ] Month view (S1)
|
||||||
- [ ] Week view (S2)
|
- [ ] Week view (S2)
|
||||||
- [ ] Day view (S3)
|
- [ ] Day view (S3)
|
||||||
- [ ] Event Detail Sheet (S4)
|
- [ ] Event Detail Sheet (S4)
|
||||||
- [ ] Multi-Calendar Filter (M3)
|
- [ ] Multi-Calendar Filter (M3)
|
||||||
- [ ] Today button + Jump-to-Date (M2)
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
- [ ] View-Switcher (M1)
|
- [ ] View-Switcher (M1)
|
||||||
- [ ] Settings screen (M4)
|
- [ ] Settings screen (M4)
|
||||||
- [ ] Empty / no-permission / no-calendars states
|
- [ ] Empty / no-permission / no-calendars states
|
||||||
|
|||||||
@@ -5,22 +5,67 @@
|
|||||||
| Version | Milestone | Status |
|
| Version | Milestone | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1 | Foundation & CI | complete |
|
| v0.1 | Foundation & CI | complete |
|
||||||
| v0.2 | Data Layer & Permission Flow | pending |
|
| v0.2 | Data Layer & Permission Flow | complete |
|
||||||
| v0.3 | Month view | pending |
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
| v0.4 | Week view | pending |
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
| v0.5 | Day view | pending |
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
| v0.6 | Event Detail Sheet | pending |
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
| v0.7 | Filter & Settings | pending |
|
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||||
|
|
||||||
## v1.0 — First Public Release
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
|
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||||
|
|
||||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||||
|
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||||
|
|
||||||
## v2.0 — Write Support
|
## v0.6 — Full event read
|
||||||
|
|
||||||
- Event create / edit / delete via `CalendarContract` writes
|
Round out the read-only model so a detail view shows everything the system
|
||||||
- Quick-add sheet
|
actually stores, before write support starts. Scope = `CalendarContract`
|
||||||
- Conflict UX (event modified externally during edit)
|
columns we don't yet read/display:
|
||||||
|
|
||||||
|
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||||
|
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||||
|
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||||
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
|
`SELF_ATTENDEE_STATUS`
|
||||||
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
|
Deliberately out of v0.6:
|
||||||
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
|
editing, so it folds into v2
|
||||||
|
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||||
|
(provider limitation, not our choice)
|
||||||
|
|
||||||
|
## v1.0 — First Public Release — shipped 2026-06-11
|
||||||
|
|
||||||
|
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||||
|
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||||
|
|
||||||
|
### Polish backlog (pre-1.0)
|
||||||
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
|
## v2.0 — Write Support (in progress)
|
||||||
|
|
||||||
|
Delivered in four releasable slices (plan:
|
||||||
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
|
guide here, not a contract — scope per slice is decided as we go.
|
||||||
|
|
||||||
|
| Version | Milestone | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
|
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
|
||||||
|
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
||||||
|
|
||||||
## v3.0 — Power-User Features
|
## v3.0 — Power-User Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-08*
|
*Last updated: 2026-06-11*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v0.1 — Foundation & CI
|
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
||||||
**Phase:** Plan 01 complete; ready to start Plan 02
|
**Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
|
||||||
|
Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
|
||||||
|
Milestone 2 runs in four slices
|
||||||
|
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
|
||||||
|
(edit event). Note: UI slices now hold release until his explicit approval.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
||||||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
||||||
- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
|
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
|
||||||
- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green
|
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
||||||
- [ ] Plan 02 written (Data Layer & Permission Flow)
|
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
||||||
|
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
|
||||||
|
- [x] Day view (S3) — single-column slice reusing the week layout
|
||||||
|
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
||||||
|
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||||
|
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||||
|
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||||
|
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||||
|
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||||
|
attendee role + self-response, foreign timezone, and linkified description
|
||||||
|
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||||
|
URL field was cut — no `CalendarContract` column backs it.)
|
||||||
|
|
||||||
|
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
||||||
|
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
||||||
|
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
||||||
|
WebCal/birthday calendars), delete from the detail screen (recurring:
|
||||||
|
"only this event" via cancelled exception / "all events in the series"),
|
||||||
|
repository + mapper tests
|
||||||
|
|
||||||
|
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
||||||
|
M3 date/time pickers with duration-preserving start moves, writable-only
|
||||||
|
calendar picker preselecting the last-used calendar, location, description),
|
||||||
|
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
||||||
|
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||||
|
domain/mapper/repository tests
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Write Plan 02: Data Layer & Permission Flow
|
1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
|
||||||
2. Execute Plan 02
|
recurrence picker
|
||||||
3. Iterate on UI design (mockups) before screens are built
|
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0
|
||||||
|
|||||||
235
CHANGELOG.md
235
CHANGELOG.md
@@ -7,6 +7,241 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.1] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Optional event-form fields with user-controlled defaults: reminders,
|
||||||
|
availability (busy/free), and visibility (default/public/private/
|
||||||
|
confidential) joined location and description as form sections. Settings
|
||||||
|
gained a "New event form" section choosing which show by default; the rest
|
||||||
|
unfold via a "More fields" picker
|
||||||
|
- Reminders editor: stacked rows with right-bound remove, full-width add
|
||||||
|
action; the picker offers one-tap presets and a custom amount + unit
|
||||||
|
(minutes/hours/days/weeks) step
|
||||||
|
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
|
||||||
|
card, optional icon + supporting line, highlighted selection). All dialogs
|
||||||
|
(calendar, visibility, more-fields, reminder presets, recurring-delete)
|
||||||
|
now use it; radio-row dialogs are retired
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event form redesigned onto the detail screen's design system: tonal cards
|
||||||
|
with gutter icons (top-aligned on tall cards), borderless inline text
|
||||||
|
fields, calendar-coloured accent bar under the title, no dividers, no
|
||||||
|
top-bar title; placeholders render clearly fainter than input
|
||||||
|
- M3 Expressive motion: the theme now provides a MotionScheme
|
||||||
|
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
|
||||||
|
as overdone), the FAB stack and "more fields" reveals animate on theme
|
||||||
|
springs
|
||||||
|
- The jump-to-today slide is direction-aware (future → today slides in from
|
||||||
|
the left, past → from the right)
|
||||||
|
- `versionName`/`versionCode` bumped to 1.2.1 / 10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The keyboard no longer pans the whole event form; the screen stays
|
||||||
|
anchored and the focused field scrolls into view (`adjustResize` +
|
||||||
|
`imePadding`)
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Create events (milestone 2, slice 2):
|
||||||
|
- A "+" FAB on the month, week, and day views opens a new full-screen event
|
||||||
|
form, prefilled with the visible day (today at the next full hour, or
|
||||||
|
09:00 on other days)
|
||||||
|
- The form covers title, all-day toggle, start/end with Material 3 date and
|
||||||
|
time pickers (moving the start drags the end along, preserving duration),
|
||||||
|
target calendar, location, and description
|
||||||
|
- The calendar picker offers only writable calendars and preselects the one
|
||||||
|
you last created an event in
|
||||||
|
- Validation on save ("ends before it starts", no writable calendar), with
|
||||||
|
the same contextual write-permission upgrade as delete
|
||||||
|
- All-day events are stored provider-correctly (UTC midnights, exclusive
|
||||||
|
end), timed events in the device time zone
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The jump-to-today pill now stacks above the new "+" FAB instead of being
|
||||||
|
the only floating action
|
||||||
|
- `versionName`/`versionCode` bumped to 1.2.0 / 9
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
|
||||||
|
- Delete action on the event detail screen, with a confirmation dialog;
|
||||||
|
recurring events choose between "Only this event" (a cancelled exception,
|
||||||
|
so the rest of the series survives) and "All events in the series"
|
||||||
|
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
|
||||||
|
dialog, but only read access is required — declining write keeps the app
|
||||||
|
fully usable read-only. Existing v1.0 installs are asked for the write
|
||||||
|
upgrade in place, on their first delete
|
||||||
|
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
|
||||||
|
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Onboarding copy no longer claims "read-only"; it now says your data stays on
|
||||||
|
the device (still no internet permission, still zero telemetry)
|
||||||
|
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
|
||||||
|
removed until editing ships in a later slice
|
||||||
|
- `versionName`/`versionCode` bumped to 1.1.0 / 8
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
First public release. Calendula is a read-only, Material 3 Expressive calendar
|
||||||
|
that lives entirely on top of Android's `CalendarContract` — every calendar
|
||||||
|
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
|
||||||
|
automatically, with zero telemetry and no internet permission.
|
||||||
|
|
||||||
|
### Highlights (accumulated across v0.1 → v0.6)
|
||||||
|
- Month, week, and day views with a view switcher, swipe navigation, and
|
||||||
|
Loading / Failure / Success states on every screen
|
||||||
|
- Full-screen event detail surfacing every readable `CalendarContract` field —
|
||||||
|
times, recurrence (humanised), location, description (with tappable links),
|
||||||
|
attendees + roles + your own response, reminders, status, availability,
|
||||||
|
access level, and foreign time zones
|
||||||
|
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
|
||||||
|
screen (theme, Material You dynamic colour, week start, app language)
|
||||||
|
- Material 3 Expressive first-run onboarding for calendar access
|
||||||
|
- German + English localization throughout
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.0.0 / 7
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full event read (v0.6): the detail screen now surfaces every readable
|
||||||
|
`CalendarContract` field that V1 had been dropping —
|
||||||
|
- **Reminders** — each configured lead time, humanised ("10 minutes before",
|
||||||
|
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
|
||||||
|
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
|
||||||
|
also strikes through its title (Confirmed shows no chip)
|
||||||
|
- **Availability** — a "Free" pill pinned top-right of the title when the
|
||||||
|
event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP
|
||||||
|
field); the default "Busy" is left implicit to avoid noise on every event
|
||||||
|
- **Access level** — a Private / Confidential chip when the event isn't public
|
||||||
|
- **Attendee role** — organizer / optional / resource badge under each
|
||||||
|
attendee, plus the device user's own response ("Your response: …") from
|
||||||
|
`Events.SELF_ATTENDEE_STATUS`
|
||||||
|
- **Time zone** — shown only for timed events pinned to a zone other than the
|
||||||
|
device's, so cross-zone events read unambiguously
|
||||||
|
- **Linked URLs** — http(s) links in the description are now tappable
|
||||||
|
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
|
||||||
|
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
|
||||||
|
status fields; mappers + unit tests cover every new column's integer codes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned the first-run grant-access screen — the onboarding a new user
|
||||||
|
sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name
|
||||||
|
eyebrow, a benefit-led headline, three trust rows (on-device, every calendar,
|
||||||
|
no tracking) with tonal icon chips, a full-width filled CTA with a trailing
|
||||||
|
arrow, and a "Read-only · no internet permission" footnote (the app declares
|
||||||
|
only `READ_CALENDAR`). The denied/recovery state shares the same shell with a
|
||||||
|
lock-badged hero and Open-settings / Try-again actions
|
||||||
|
- `versionName`/`versionCode` bumped to 0.6.0 / 6
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
|
||||||
|
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
|
||||||
|
are surfaced by linkifying the description instead
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendar filter (M3): the navigation drawer now hosts the calendar list
|
||||||
|
inline — every calendar grouped by account, each with a colour swatch and a
|
||||||
|
visibility switch. Hiding a calendar is persisted app-side (DataStore,
|
||||||
|
separate from the system VISIBLE flag) and applied centrally in the
|
||||||
|
repository, so month/week/day re-filter live the moment a switch flips.
|
||||||
|
The drawer was trimmed to just Today, the calendar filter, and Settings
|
||||||
|
(the stubbed jump-to-date entry was removed; jump-to-date was later cut
|
||||||
|
from scope entirely)
|
||||||
|
- Settings (M4): a full-screen destination with
|
||||||
|
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
||||||
|
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
||||||
|
- **Language** — app language (System / Deutsch / English) via per-app
|
||||||
|
locales, persisted across cold starts down to Android 10
|
||||||
|
- **About** — version, license, and a link to the source on Gitea
|
||||||
|
- Week-start preference now drives the month grid and week view; "Automatic"
|
||||||
|
follows the active locale (Monday in DE, Sunday in en-US)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Theme is driven by one activity-scoped settings source, so a theme or
|
||||||
|
dynamic-colour change applies app-wide immediately
|
||||||
|
- `versionName`/`versionCode` bumped to 0.5.0 / 5 (the in-repo version had
|
||||||
|
lagged behind the release tags); the About screen reads it directly
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Event detail (S4): full-screen destination (MD3 list→detail, not a bottom
|
||||||
|
sheet) opened by tapping an event in the week/day timeline — title with a
|
||||||
|
calendar-colour accent line, a card per field (when, calendar, location,
|
||||||
|
description, attendees, recurrence) with leading icons, location tap opens a
|
||||||
|
maps intent, Loading/Failure/Success states, slide-in/out over the calendar
|
||||||
|
- Human-readable recurrence: RRULE rendered as e.g. "Every week on _Tue_ and
|
||||||
|
_Thu_ until 31 Dec 2026" (FREQ/INTERVAL/BYDAY/UNTIL/COUNT, abbreviated +
|
||||||
|
italicised day names, localized list formatting), with a generic fallback
|
||||||
|
- Month → day navigation: tapping a day cell opens the day view on that date
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recurring events failed to open in the detail view: the series row stores
|
||||||
|
DURATION instead of DTEND, so the mapper dropped it (EventNotFound). The
|
||||||
|
detail now keeps such events and shows the tapped occurrence's own times
|
||||||
|
(from CalendarContract.Instances) instead of the series start
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Month view (S1): Material 3 Expressive card-per-day grid (only the current
|
||||||
|
month's weeks; neighbouring days left blank), per-day event dots with "+N"
|
||||||
|
overflow, today emphasised via `primaryContainer`, spring-based press
|
||||||
|
feedback from the active motion scheme, swipe + drawer navigation,
|
||||||
|
Loading/Failure/Success states
|
||||||
|
- Week view (S2): vertical time schedule with overlap-resolved lanes,
|
||||||
|
separate all-day strip, midnight-spanning events clipped per day, swipe
|
||||||
|
navigation, Loading/Failure/Success states
|
||||||
|
- Day view (S3): single-column slice of the week schedule reusing its
|
||||||
|
overlap-lane layout, per-day swipe navigation, noon-centred scroll that
|
||||||
|
persists across swipes, animated all-day strip, compact top bar with the
|
||||||
|
full date, Loading/Failure/Success states
|
||||||
|
- Functional view-switcher (M1) cycling Month ↔ Week ↔ Day
|
||||||
|
- Shared calendar UI building blocks in `ui/common/` (navigation drawer,
|
||||||
|
failure screen, view-switcher pill, color pastelizer, observable locale)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Throwaway debug screen — superseded by the month view
|
||||||
|
|
||||||
|
## [0.2.1] — 2026-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Regenerated the F-Droid catalog `icon.png` (512x512, both locales) so it
|
||||||
|
is pixel-faithful to the on-device adaptive launcher icon: same slate
|
||||||
|
background (`#5C6B7A`), off-white mark (`#FAF6F0`), and the foreground
|
||||||
|
group transform (`scale 0.5`, pivot `114,108`, translate `2,8`) baked in.
|
||||||
|
- Added `design/icon/calendula_launcher.svg` — the composed full-bleed
|
||||||
|
icon (background + transformed mark) as the single source of truth for
|
||||||
|
store/F-Droid renders.
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-06-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Domain models for calendars, event instances, event detail, attendees
|
||||||
|
- `CalendarContract`-backed `CalendarRepository` with `ContentObserver`-driven live updates
|
||||||
|
- DataStore preference for app-side hidden-calendar visibility
|
||||||
|
- `READ_CALENDAR` permission flow (rationale + denied recovery + system-settings shortcut)
|
||||||
|
- Wegwerfbarer Debug-Screen: zeigt alle Kalender + die nächsten 50 Termine ab heute
|
||||||
|
- Hilt-Wiring für Data-Layer (Repository, DataSource, DataStore, IO-Dispatcher)
|
||||||
|
- Unit-Tests für Cursor-Mapping (alle §8-Defensiv-Cases), Repository-Flows mit Turbine, DataStore round-trip
|
||||||
|
- Instrumented smoke test against the real CalendarContract provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned launcher icon: line-art calendar with a stylized "1" inside
|
||||||
|
(kalendae reference) and a small calendula bloom badge in the
|
||||||
|
bottom-right corner. Replaces the simple "1"-only foreground from
|
||||||
|
v0.1.0. Source SVG checked in at `design/icon/calendula_mark.svg`,
|
||||||
|
also used to regenerate the F-Droid catalog `icon.png` (512x512)
|
||||||
|
per locale.
|
||||||
|
|
||||||
## [0.1.1] — 2026-06-08
|
## [0.1.1] — 2026-06-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 10
|
||||||
versionName = "0.1.0"
|
versionName = "1.2.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,7 @@ kotlin {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -98,6 +99,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
@@ -121,6 +124,7 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
|
androidTestImplementation(libs.truth)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,27 @@ import androidx.compose.ui.test.assertIsDisplayed
|
|||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke: launches MainActivity and asserts the permission rationale renders
|
||||||
|
* when calendar access has not yet been granted. Without GrantPermissionRule
|
||||||
|
* the system reports NOT_GRANTED on first launch so we land in PermissionScreen.
|
||||||
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class MainActivitySmokeTest {
|
class MainActivitySmokeTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
@Test
|
private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
|
||||||
fun appName_isDisplayed_onLaunch() {
|
|
||||||
composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun tagline_isDisplayed_onLaunch() {
|
fun permissionRationale_isDisplayed_onLaunch_withoutPermission() {
|
||||||
composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed()
|
composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
|
||||||
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.rule.GrantPermissionRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CalendarRepositorySmokeTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val permissionRule: GrantPermissionRule =
|
||||||
|
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR)
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
private fun newRepo(): CalendarRepositoryImpl {
|
||||||
|
val dataSource = AndroidCalendarDataSource(context)
|
||||||
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||||
|
)
|
||||||
|
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun calendars_returnsListWithoutCrashing() = runBlocking {
|
||||||
|
val repo = newRepo()
|
||||||
|
val first = repo.calendars().first()
|
||||||
|
assertThat(first).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun instances_returnsListWithoutCrashing() = runBlocking {
|
||||||
|
val repo = newRepo()
|
||||||
|
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
val oneDayLater = Instant.fromEpochMilliseconds(System.currentTimeMillis() + 86_400_000L)
|
||||||
|
val first = repo.instances(now..oneDayLater).first()
|
||||||
|
assertThat(first).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PermissionScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rationale_renders_title_and_button() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
PermissionScreen(onGranted = {})
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(res.getString(R.string.permission_request_button))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
@@ -17,12 +18,24 @@
|
|||||||
tools:targetApi="35">
|
tools:targetApi="35">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="autoStoreLocales"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -25,35 +22,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
CalendulaTheme {
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
}
|
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val darkTheme = when (settings.themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
CalendulaTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
dynamicColor = settings.dynamicColor,
|
||||||
|
) {
|
||||||
|
RootScreen(modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name),
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_tagline),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderPreview() {
|
|
||||||
CalendulaTheme { PlaceholderScreen() }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-shaped seam over Android's ContentResolver. Returns parsed lists so
|
||||||
|
* the repository can be unit-tested without constructing Cursors or
|
||||||
|
* ContentObservers on the JVM.
|
||||||
|
*
|
||||||
|
* Cursor handling and the ContentObserver-to-listener bridge live entirely
|
||||||
|
* in AndroidCalendarDataSource.
|
||||||
|
*/
|
||||||
|
interface CalendarDataSource {
|
||||||
|
fun calendars(): List<CalendarSource>
|
||||||
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/** Insert a new event; returns the new `Events._ID`. */
|
||||||
|
fun insertEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a single occurrence of a recurring event by inserting a
|
||||||
|
* cancelled exception at [beginMillis] (the occurrence's `Instances.BEGIN`).
|
||||||
|
*/
|
||||||
|
fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
|
fun registerChangeListener(listener: () -> Unit)
|
||||||
|
fun unregisterChangeListener(listener: () -> Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidCalendarDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : CalendarDataSource {
|
||||||
|
|
||||||
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
|
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||||
|
|
||||||
|
override fun calendars(): List<CalendarSource> = resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
CalendarProjection.COLUMNS,
|
||||||
|
null, null,
|
||||||
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
|
||||||
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, beginMillis)
|
||||||
|
ContentUris.appendId(this, endMillis)
|
||||||
|
}.build()
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
InstanceProjection.COLUMNS,
|
||||||
|
null, null,
|
||||||
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
|
val attendees = queryAttendees(eventId)
|
||||||
|
val reminders = queryReminders(eventId)
|
||||||
|
return resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
EventDetailProjection.COLUMNS,
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) null
|
||||||
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm): Long {
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
|
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 ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEvent(eventId: Long) {
|
||||||
|
val deleted = resolver.delete(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
val uri = ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
|
)
|
||||||
|
resolver.insert(uri, values)
|
||||||
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun registerChangeListener(listener: () -> Unit) {
|
||||||
|
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
|
override fun onChange(selfChange: Boolean) {
|
||||||
|
listener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers[listener] = obs
|
||||||
|
resolver.registerContentObserver(
|
||||||
|
CalendarContract.CONTENT_URI,
|
||||||
|
/* notifyForDescendants = */ true,
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||||
|
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
AttendeeProjection.COLUMNS,
|
||||||
|
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||||
|
|
||||||
|
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
ReminderProjection.COLUMNS,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
|
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||||
|
|
||||||
|
/** Iterate every row and map; skips nothing. */
|
||||||
|
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
||||||
|
while (moveToNext()) add(mapper(this@mapAll))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Iterate every row and map; drops nulls. */
|
||||||
|
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
||||||
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarDataSource"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
|
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
||||||
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||||
|
?: Fallbacks.UNNAMED_CALENDAR,
|
||||||
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
|
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
||||||
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
|
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
||||||
|
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
interface CalendarRepository {
|
||||||
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
suspend fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
||||||
|
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoSuchEventException(eventId: Long) :
|
||||||
|
NoSuchElementException("No event with id=$eventId")
|
||||||
|
|
||||||
|
/** A ContentResolver write affected no rows or returned no URI. */
|
||||||
|
class WriteFailedException(operation: String) :
|
||||||
|
RuntimeException("Calendar write failed: $operation")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ContentResolver-backed observer for the lifetime of the App process.
|
||||||
|
* Each public flow re-queries on subscribe and after every tick from the
|
||||||
|
* data source.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
|
private val dataSource: CalendarDataSource,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun calendars(): Flow<List<CalendarSource>> =
|
||||||
|
ticks
|
||||||
|
.onStart { emit(Unit) }
|
||||||
|
.reQuery { dataSource.calendars() }
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
|
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||||
|
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||||
|
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||||
|
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||||
|
// so the filter sheet can list and re-enable hidden calendars.
|
||||||
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
|
combine(
|
||||||
|
ticks
|
||||||
|
.onStart { emit(Unit) }
|
||||||
|
.reQuery {
|
||||||
|
dataSource.instances(
|
||||||
|
beginMillis = range.start.toEpochMillis(),
|
||||||
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { instances, hidden ->
|
||||||
|
if (hidden.isEmpty()) instances
|
||||||
|
else instances.filterNot { it.calendarId in hidden }
|
||||||
|
}.flowOn(io)
|
||||||
|
|
||||||
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
|
dataSource.insertEvent(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
|
dataSource.deleteEvent(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
collect { emit(block()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view over a single row's columns by index. Lets the mappers work
|
||||||
|
* on pure-Kotlin test fixtures (MapColumnReader) on the JVM, while the
|
||||||
|
* production path adapts an Android Cursor row via CursorColumnReader.
|
||||||
|
*/
|
||||||
|
internal interface ColumnReader {
|
||||||
|
fun getLong(index: Int): Long
|
||||||
|
fun getString(index: Int): String?
|
||||||
|
fun getInt(index: Int): Int
|
||||||
|
fun isNull(index: Int): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
|
||||||
|
override fun getLong(index: Int): Long = cursor.getLong(index)
|
||||||
|
override fun getString(index: Int): String? = cursor.getString(index)
|
||||||
|
override fun getInt(index: Int): Int = cursor.getInt(index)
|
||||||
|
override fun isNull(index: Int): Boolean = cursor.isNull(index)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
|
||||||
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
|
attendees: List<Attendee>,
|
||||||
|
reminders: List<Reminder>,
|
||||||
|
): EventDetail? {
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
|
if (begin < 0L) {
|
||||||
|
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
|
// specific occurrence supply the real per-occurrence times from
|
||||||
|
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||||
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
|
begin
|
||||||
|
} else {
|
||||||
|
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||||
|
if (rawEnd < begin) {
|
||||||
|
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
rawEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
|
|
||||||
|
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||||
|
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
} else {
|
||||||
|
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val instance = EventInstance(
|
||||||
|
instanceId = eventId,
|
||||||
|
eventId = eventId,
|
||||||
|
calendarId = getLong(EventDetailProjection.IDX_CALENDAR_ID),
|
||||||
|
title = title,
|
||||||
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
||||||
|
color = color,
|
||||||
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
|
)
|
||||||
|
|
||||||
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventDetail(
|
||||||
|
instance = instance,
|
||||||
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
|
attendees = attendees,
|
||||||
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
|
reminders = reminders,
|
||||||
|
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.
|
||||||
|
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||||
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
||||||
|
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||||
|
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||||
|
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
||||||
|
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
|
||||||
|
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ColumnReader.toReminder(): Reminder = Reminder(
|
||||||
|
minutes = getInt(ReminderProjection.IDX_MINUTES),
|
||||||
|
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED -> AttendeeStatus.Accepted
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> AttendeeStatus.Declined
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE -> AttendeeStatus.Tentative
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||||
|
else -> AttendeeStatus.Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
|
||||||
|
else -> AttendeeRelationship.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
|
||||||
|
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
|
||||||
|
else -> AttendeeType.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
|
||||||
|
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
|
||||||
|
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
|
||||||
|
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAvailability(raw: Int): Availability = when (raw) {
|
||||||
|
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
|
||||||
|
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
|
||||||
|
else -> Availability.Busy
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
|
||||||
|
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
|
||||||
|
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
|
||||||
|
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
|
||||||
|
else -> AccessLevel.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
|
||||||
|
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
|
||||||
|
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
|
||||||
|
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
|
||||||
|
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
|
||||||
|
else -> ReminderMethod.Default
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
|
internal data class EventWriteTimes(
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long,
|
||||||
|
val timezone: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
|
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||||
|
* timed events resolve their wall-clock values in [zone].
|
||||||
|
*/
|
||||||
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.date.toJavaLocalDate()
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.date.toJavaLocalDate().plusDays(1)
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
timezone = "UTC",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
timezone = zone.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Availability.toProviderValue(): Int = when (this) {
|
||||||
|
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
||||||
|
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
||||||
|
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||||
|
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
|
||||||
|
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
|
||||||
|
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||||
|
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
private const val TAG = "InstanceMapper"
|
||||||
|
|
||||||
|
internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||||
|
val begin = getLong(InstanceProjection.IDX_BEGIN)
|
||||||
|
val end = getLong(InstanceProjection.IDX_END)
|
||||||
|
|
||||||
|
if (begin < 0L) {
|
||||||
|
Log.w(TAG, "Dropping row with negative begin=$begin")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (end < begin) {
|
||||||
|
Log.w(TAG, "Dropping row with end=$end < begin=$begin")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
|
|
||||||
|
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||||
|
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
|
} else {
|
||||||
|
getInt(InstanceProjection.IDX_EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
|
||||||
|
eventId = getLong(InstanceProjection.IDX_EVENT_ID),
|
||||||
|
calendarId = getLong(InstanceProjection.IDX_CALENDAR_ID),
|
||||||
|
title = title,
|
||||||
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||||
|
color = color,
|
||||||
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ internal object CalendarProjection {
|
|||||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||||
CalendarContract.Calendars.VISIBLE,
|
CalendarContract.Calendars.VISIBLE,
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -18,6 +19,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_ACCOUNT_TYPE = 3
|
const val IDX_ACCOUNT_TYPE = 3
|
||||||
const val IDX_COLOR = 4
|
const val IDX_COLOR = 4
|
||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -60,6 +62,11 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -74,6 +81,11 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ALL_DAY = 9
|
const val IDX_ALL_DAY = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_CALENDAR_ID = 11
|
const val IDX_CALENDAR_ID = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
@@ -81,11 +93,25 @@ internal object AttendeeProjection {
|
|||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_NAME = 0
|
const val IDX_NAME = 0
|
||||||
const val IDX_EMAIL = 1
|
const val IDX_EMAIL = 1
|
||||||
const val IDX_STATUS = 2
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_RELATIONSHIP = 3
|
||||||
|
const val IDX_TYPE = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ReminderProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
CalendarContract.Reminders.METHOD,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_MINUTES = 0
|
||||||
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
internal object Fallbacks {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = "calendula_prefs",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class DataBindModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindCalendarDataSource(
|
||||||
|
impl: AndroidCalendarDataSource,
|
||||||
|
): CalendarDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindCalendarRepository(
|
||||||
|
impl: CalendarRepositoryImpl,
|
||||||
|
): CalendarRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DataProvideModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.calendulaDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IoDispatcher
|
||||||
|
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class IoDispatcher
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-side preference for "calendars the user has hidden in this app",
|
||||||
|
* separate from the system's per-calendar VISIBLE flag.
|
||||||
|
*
|
||||||
|
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||||
|
* silently dropped (defensive — see CalendarPrefsTest).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CalendarPrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||||
|
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
prefs.remove(HIDDEN_IDS_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar the user last created an event in; preselected in the
|
||||||
|
* event form. Null until the first event is created.
|
||||||
|
*/
|
||||||
|
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
|
||||||
|
prefs[LAST_USED_CALENDAR_KEY]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastUsedCalendarId(id: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
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.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import java.time.temporal.WeekFields
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Light/dark override. SYSTEM follows the device setting. */
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
|
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||||
|
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||||
|
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||||
|
*/
|
||||||
|
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||||
|
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||||
|
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||||
|
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||||
|
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
|
* dynamic colour, and week start. Language is handled separately through
|
||||||
|
* AppCompatDelegate (which persists its own per-app locale).
|
||||||
|
*
|
||||||
|
* Enum prefs round-trip by [Enum.name]; an unknown/garbage stored value falls
|
||||||
|
* back to the default rather than throwing (see SettingsPrefsTest).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SettingsPrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
|
||||||
|
prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dynamicColor: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DYNAMIC_COLOR_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||||
|
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
|
store.edit { it[THEME_MODE_KEY] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDynamicColor(enabled: Boolean) {
|
||||||
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
|
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
|
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||||
|
* the factory default, an empty string means "none". Unknown names are
|
||||||
|
* dropped defensively, like the other enum prefs.
|
||||||
|
*/
|
||||||
|
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
|
||||||
|
parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||||
|
val updated = if (enabled) current + field else current - field
|
||||||
|
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
|
null -> DEFAULT_FORM_FIELDS
|
||||||
|
else -> stored.split(',')
|
||||||
|
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
|
* wall-clock values in the device zone; the data layer translates them to
|
||||||
|
* provider millis (all-day events normalise to UTC midnights there).
|
||||||
|
*/
|
||||||
|
data class EventForm(
|
||||||
|
val calendarId: Long?,
|
||||||
|
val title: String = "",
|
||||||
|
val isAllDay: Boolean = false,
|
||||||
|
val start: LocalDateTime,
|
||||||
|
val end: LocalDateTime,
|
||||||
|
val location: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||||
|
val reminders: List<Int> = emptyList(),
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's optional sections. Which ones show by default is a user setting;
|
||||||
|
* the rest unfold behind a "more fields" button.
|
||||||
|
*/
|
||||||
|
enum class EventFormField {
|
||||||
|
Location,
|
||||||
|
Description,
|
||||||
|
Reminders,
|
||||||
|
Availability,
|
||||||
|
Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventFormProblem {
|
||||||
|
/** No target calendar — none picked and no writable calendar exists. */
|
||||||
|
NoCalendar,
|
||||||
|
EndBeforeStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation; an empty set means the form can be saved. A blank title is
|
||||||
|
* allowed (display falls back to "(No title)", matching the provider), and a
|
||||||
|
* zero-length timed event is allowed (spec §8: instant events exist).
|
||||||
|
*/
|
||||||
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
|
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||||
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ data class CalendarSource(
|
|||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
|
* (`Calendars.CALENDAR_ACCESS_LEVEL` >= contributor). False for WebCal
|
||||||
|
* subscriptions, birthday calendars and other read-only sources.
|
||||||
|
*/
|
||||||
|
val canModifyContents: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -29,12 +35,34 @@ data class EventDetail(
|
|||||||
val organizer: String?,
|
val organizer: String?,
|
||||||
val attendees: List<Attendee>,
|
val attendees: List<Attendee>,
|
||||||
val rrule: String?,
|
val rrule: String?,
|
||||||
|
/** Reminders (VALARM) configured on the event, ascending lead time. */
|
||||||
|
val reminders: List<Reminder> = emptyList(),
|
||||||
|
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
|
||||||
|
val eventTimezone: String? = null,
|
||||||
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
val status: AttendeeStatus,
|
val status: AttendeeStatus,
|
||||||
|
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
|
||||||
|
val relationship: AttendeeRelationship = AttendeeRelationship.None,
|
||||||
|
/** Required / optional / resource (`ATTENDEE_TYPE`). */
|
||||||
|
val type: AttendeeType = AttendeeType.None,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Reminder(
|
||||||
|
/** Lead time before the event start, in minutes. `-1` means the provider default. */
|
||||||
|
val minutes: Int,
|
||||||
|
val method: ReminderMethod,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AttendeeStatus {
|
enum class AttendeeStatus {
|
||||||
@@ -45,6 +73,48 @@ enum class AttendeeStatus {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AttendeeRelationship {
|
||||||
|
Organizer,
|
||||||
|
Attendee,
|
||||||
|
Performer,
|
||||||
|
Speaker,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AttendeeType {
|
||||||
|
Required,
|
||||||
|
Optional,
|
||||||
|
Resource,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ReminderMethod {
|
||||||
|
Alert,
|
||||||
|
Email,
|
||||||
|
Sms,
|
||||||
|
Alarm,
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Availability {
|
||||||
|
Busy,
|
||||||
|
Free,
|
||||||
|
Tentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AccessLevel {
|
||||||
|
Default,
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
147
app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
Normal file
147
app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarHost(modifier: Modifier = Modifier) {
|
||||||
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
|
pendingDayIso = date.toString()
|
||||||
|
view = CalendarView.Day
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
|
// it overlays whichever calendar view is active. We forward the tapped
|
||||||
|
// occurrence's own times (eventId + begin + end, packed as a saveable
|
||||||
|
// long[]) so recurring events show the correct date, not the series start.
|
||||||
|
// [heldKey] keeps the last shown key alive through the slide-out (when
|
||||||
|
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
|
||||||
|
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||||
|
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||||
|
val key = longArrayOf(
|
||||||
|
event.eventId,
|
||||||
|
event.start.toEpochMilliseconds(),
|
||||||
|
event.end.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
heldKey = key
|
||||||
|
detailKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
|
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
|
val onCreateEvent: (LocalDate) -> Unit = { date ->
|
||||||
|
heldCreateIso = date.toString()
|
||||||
|
createDateIso = date.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
when (view) {
|
||||||
|
CalendarView.Week -> WeekScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
)
|
||||||
|
CalendarView.Day -> DayScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
initialDateIso = pendingDayIso,
|
||||||
|
)
|
||||||
|
CalendarView.Month -> MonthScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the live key; fall back to the held one only while sliding out.
|
||||||
|
val activeKey = detailKey ?: heldKey
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = detailKey != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
activeKey?.let { key ->
|
||||||
|
EventDetailScreen(
|
||||||
|
eventId = key[0],
|
||||||
|
beginMillis = key[1],
|
||||||
|
endMillis = key[2],
|
||||||
|
onBack = { detailKey = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = createDateIso != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = iso,
|
||||||
|
onClose = { createDateIso = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSettings,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SettingsScreen(onBack = { showSettings = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RootScreen(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
DisposableEffect(lifecycle) {
|
||||||
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle.addObserver(obs)
|
||||||
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission) {
|
||||||
|
CalendarHost(modifier = modifier)
|
||||||
|
} else {
|
||||||
|
PermissionScreen(
|
||||||
|
onGranted = { hasPermission = true },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||||
|
* - Keeps the hue (so users still recognise their calendars)
|
||||||
|
* - Caps saturation so harsh provider colors stop screaming
|
||||||
|
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||||
|
*/
|
||||||
|
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||||
|
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||||
|
hsv[2] = if (dark) 0.82f else 0.72f
|
||||||
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Today
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
|
*
|
||||||
|
* Visual language (kept deliberately small so sizes don't drift):
|
||||||
|
* - Drawer title — `titleLarge`
|
||||||
|
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
||||||
|
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
|
||||||
|
* (`labelLarge` label + a single 24dp leading icon)
|
||||||
|
*
|
||||||
|
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
|
||||||
|
* its checkboxes lives here rather than in a separate sheet — plus the "today"
|
||||||
|
* jump and a Settings entry (M4). The host screen owns the drawer state.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarDrawer(
|
||||||
|
onToday: () -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
||||||
|
label = { Text(stringResource(R.string.month_today_action)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = onToday,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
||||||
|
// between the top actions and the pinned Settings entry.
|
||||||
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
|
CalendarFilterList(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
|
label = { Text(stringResource(R.string.month_action_settings)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = onSettings,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level grouping label in the drawer. Text only, so it never reads as a
|
||||||
|
* tappable nav item. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerSectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FAB stack shared by the three calendar views: a persistent "+" to
|
||||||
|
* create an event, with the jump-to-today pill appearing above it whenever
|
||||||
|
* the view isn't anchored on today.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarFabColumn(
|
||||||
|
todayVisible: Boolean,
|
||||||
|
todayText: String,
|
||||||
|
onToday: () -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = todayVisible,
|
||||||
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = onToday,
|
||||||
|
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
text = { Text(todayText) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FloatingActionButton(onClick = onCreate) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
|
* One explanation line + one recovery action, never a toast.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||||
|
val titleRes = when (reason) {
|
||||||
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
|
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||||
|
FailureReason.Unknown,
|
||||||
|
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||||
|
}
|
||||||
|
val actionRes = when (reason) {
|
||||||
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||||
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||||
|
else -> R.string.state_retry
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
FilledTonalButton(onClick = onRetry) {
|
||||||
|
Text(stringResource(actionRes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
|
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||||
|
* springy settle, rather than a fixed easing curve.
|
||||||
|
*
|
||||||
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
|
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal slide for navigating between adjacent months/weeks.
|
||||||
|
*
|
||||||
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
|
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||||
|
*/
|
||||||
|
fun calendarSlideTransition(
|
||||||
|
slideDir: Int,
|
||||||
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
): ContentTransform {
|
||||||
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
|
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level calendar views the user can switch between (spec M1).
|
||||||
|
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
||||||
|
*/
|
||||||
|
enum class CalendarView {
|
||||||
|
Month,
|
||||||
|
Week,
|
||||||
|
Day,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Views that actually have a screen today. The view-switcher pill cycles
|
||||||
|
* through these in order.
|
||||||
|
*/
|
||||||
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
|
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||||
|
|
||||||
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
val i = available.indexOf(this)
|
||||||
|
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||||
|
return available[(i + 1) % available.size]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.core.os.ConfigurationCompat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||||
|
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||||
|
* weekday/month name formatting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun currentLocale(): Locale {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
return remember(configuration) {
|
||||||
|
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard pick in a selection dialog: a full-width tonal card,
|
||||||
|
* optionally with a leading icon and a supporting line; the selected option
|
||||||
|
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
||||||
|
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OptionCard(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
supportingText: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
||||||
|
labelColor: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
val contentColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when {
|
||||||
|
iconTint.isSpecified -> iconTint
|
||||||
|
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (labelColor.isSpecified) labelColor else contentColor,
|
||||||
|
)
|
||||||
|
if (supportingText != null) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ViewSwitcherPill(
|
||||||
|
current: CalendarView,
|
||||||
|
onCycle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val labelRes = when (current) {
|
||||||
|
CalendarView.Month -> R.string.view_month
|
||||||
|
CalendarView.Week -> R.string.view_week
|
||||||
|
CalendarView.Day -> R.string.view_day
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onCycle,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(stringResource(labelRes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
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.Menu
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
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.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
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.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val HOUR_HEIGHT = 56.dp
|
||||||
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
|
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
|
if (allDay.isEmpty()) return 0.dp
|
||||||
|
val lanes = allDay.maxOf { it.lane } + 1
|
||||||
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DayScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initialDateIso: String? = null,
|
||||||
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// When opened from the month grid, anchor to the tapped date.
|
||||||
|
LaunchedEffect(initialDateIso) {
|
||||||
|
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// The all-day strip shares the app bar's scrolled colour so the whole top
|
||||||
|
// region elevates together once the timeline scrolls under it.
|
||||||
|
val topSectionColor by animateColorAsState(
|
||||||
|
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "day-top-section-color",
|
||||||
|
)
|
||||||
|
|
||||||
|
val isOnToday = when (val s = state) {
|
||||||
|
is DayUiState.Success -> s.date == s.today
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
|
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||||
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
|
val jumpToToday = {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
// Open only via the menu button — edge-swipe would fight the day swipe.
|
||||||
|
gesturesEnabled = drawerState.isOpen,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
DayTopBar(
|
||||||
|
date = date,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnToday,
|
||||||
|
todayText = stringResource(R.string.day_today_action),
|
||||||
|
onToday = jumpToToday,
|
||||||
|
onCreate = { onCreateEvent(date) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
DayContent(
|
||||||
|
state = state,
|
||||||
|
slideDir = slideDir,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
onSwipeNext = goNext,
|
||||||
|
onSwipePrev = goPrev,
|
||||||
|
onRetry = jumpToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayContent(
|
||||||
|
state: DayUiState,
|
||||||
|
slideDir: Int,
|
||||||
|
topSectionColor: Color,
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||||
|
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||||
|
// into the day view (i.e. when arriving from the month/week view).
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
val maxV = scrollState.maxValue
|
||||||
|
val target = with(density) {
|
||||||
|
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||||
|
}.coerceIn(0, maxV)
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
|
// day during a swipe, so the strip slides along but never jumps in height.
|
||||||
|
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||||
|
val allDayHeight by animateDpAsState(
|
||||||
|
targetValue = targetAllDayHeight,
|
||||||
|
label = "day-all-day-strip-height",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
|
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||||
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { dragAccum = 0f },
|
||||||
|
onDragEnd = {
|
||||||
|
when {
|
||||||
|
dragAccum < -threshold -> onSwipeNext()
|
||||||
|
dragAccum > threshold -> onSwipePrev()
|
||||||
|
}
|
||||||
|
dragAccum = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { dragAccum = 0f },
|
||||||
|
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
modifier = modifier.then(swipeModifier),
|
||||||
|
contentKey = { s ->
|
||||||
|
when (s) {
|
||||||
|
is DayUiState.Success -> "success-${s.date}"
|
||||||
|
is DayUiState.Failure -> "failure-${s.reason}"
|
||||||
|
DayUiState.Loading -> "loading"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||||
|
label = "day-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
DayUiState.Loading -> DayLoading()
|
||||||
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is DayUiState.Success -> DaySuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DaySuccess(
|
||||||
|
state: DayUiState.Success,
|
||||||
|
topSectionColor: Color,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
|
// so the timeline sits directly under the app bar.
|
||||||
|
AllDayStrip(
|
||||||
|
state = state,
|
||||||
|
height = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(topSectionColor),
|
||||||
|
)
|
||||||
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
|
// scrolling timeline below.
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DayTopBar(
|
||||||
|
date: LocalDate,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = formatDayTitle(date),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AllDayStrip(
|
||||||
|
state: DayUiState.Success,
|
||||||
|
height: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||||
|
// inside it so the content area is lanes * row height.
|
||||||
|
.height(height)
|
||||||
|
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||||
|
) {
|
||||||
|
// Keep the gutter-width offset so the bars line up with the day column.
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||||
|
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||||
|
// while the height animates.
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
val barWidth = maxWidth
|
||||||
|
state.allDay.forEach { span ->
|
||||||
|
AllDayBar(
|
||||||
|
event = span.event,
|
||||||
|
dark = dark,
|
||||||
|
onClick = { onEventClick(span.event) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||||
|
.width(barWidth)
|
||||||
|
.height(ALL_DAY_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AllDayBar(
|
||||||
|
event: EventInstance,
|
||||||
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
.semantics { contentDescription = title },
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Timeline(
|
||||||
|
state: DayUiState.Success,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
|
// soft corners are permanent at any scroll position.
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Hour gutter (scrolls in sync with the day column)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(GUTTER_WIDTH)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(HOUR_HEIGHT),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = "%02d".format(h),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
DayColumnCard(
|
||||||
|
blocks = state.timed,
|
||||||
|
dark = dark,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(totalHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayColumnCard(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
dark: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
|
shape = RectangleShape,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val colWidth = maxWidth
|
||||||
|
blocks.forEach { block ->
|
||||||
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||||
|
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||||
|
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||||
|
EventBlock(
|
||||||
|
block = block,
|
||||||
|
dark = dark,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
|
.width(laneWidth)
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventBlock(
|
||||||
|
block: TimedBlock,
|
||||||
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||||
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = if (showTime) 1 else 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
if (showTime) {
|
||||||
|
Text(
|
||||||
|
text = timeLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayLoading() {
|
||||||
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minToHm(min: Int): String =
|
||||||
|
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||||
|
|
||||||
|
private fun formatDayTitle(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,25 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
||||||
|
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
||||||
|
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
||||||
|
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
||||||
|
*/
|
||||||
|
sealed interface DayUiState {
|
||||||
|
data object Loading : DayUiState
|
||||||
|
data class Failure(val reason: FailureReason) : DayUiState
|
||||||
|
data class Success(
|
||||||
|
val date: LocalDate,
|
||||||
|
val today: LocalDate,
|
||||||
|
/** All-day/multi-day events covering this day, stacked by lane. */
|
||||||
|
val allDay: List<AllDaySpan>,
|
||||||
|
/** Timed events clipped to this day with overlap lanes resolved. */
|
||||||
|
val timed: List<TimedBlock>,
|
||||||
|
) : DayUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
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 de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
|
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.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class DayViewModel @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 _date = MutableStateFlow(todayDate)
|
||||||
|
val date: StateFlow<LocalDate> = _date
|
||||||
|
|
||||||
|
val state: StateFlow<DayUiState> = _date
|
||||||
|
.flatMapLatest { day ->
|
||||||
|
val range = dayRange(day, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(day, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = DayUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToPrev() {
|
||||||
|
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToNext() {
|
||||||
|
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_date.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to a specific date (e.g. when opened from the month grid). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_date.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
day: LocalDate,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): DayUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val days = listOf(day)
|
||||||
|
val allDay = instances.filter { it.isAllDay }
|
||||||
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
|
return DayUiState.Success(
|
||||||
|
date = day,
|
||||||
|
today = todayDate,
|
||||||
|
allDay = layoutAllDay(allDay, days, zone),
|
||||||
|
timed = layoutDay(timed, day, zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-open instant range covering the single calendar [date]. */
|
||||||
|
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||||
|
val from = date.atStartOfDayIn(zone)
|
||||||
|
val to = date.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return from..to
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.icu.text.ListFormatter
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
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.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||||
|
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||||
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
|
* be deleted from here (v1.1); edit follows in v1.3.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EventDetailScreen(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
|
viewModel.open(eventId, beginMillis, endMillis)
|
||||||
|
}
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
|
// upgrade in place. Granting continues straight into the confirm dialog.
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) showDeleteDialog = true
|
||||||
|
}
|
||||||
|
val onDeleteClick = {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
showDeleteDialog = true
|
||||||
|
} else {
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
|
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
when (deleteState) {
|
||||||
|
DeleteUiState.Deleted -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
DeleteUiState.Failed -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(deleteFailedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.NeedsPermission -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Only writable calendars get actions — WebCal subscriptions,
|
||||||
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
|
val s = state
|
||||||
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onDeleteClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
val contentModifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
when (val s = state) {
|
||||||
|
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
|
||||||
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
|
reason = s.reason,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
)
|
||||||
|
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loaded = state
|
||||||
|
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||||
|
DeleteEventDialog(
|
||||||
|
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||||
|
onConfirm = { wholeSeries ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.delete(wholeSeries)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete confirmation. Recurring events choose between cancelling just the
|
||||||
|
* tapped occurrence (default) and removing the whole series.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteEventDialog(
|
||||||
|
isRecurring: Boolean,
|
||||||
|
onConfirm: (wholeSeries: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var wholeSeries by rememberSaveable { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isRecurring) R.string.event_delete_recurring_title
|
||||||
|
else R.string.event_delete_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (isRecurring) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { wholeSeries = false },
|
||||||
|
selected = !wholeSeries,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { wholeSeries = true },
|
||||||
|
selected = wholeSeries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.event_delete_body))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||||
|
val detail = state.detail
|
||||||
|
val instance = detail.instance
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val locale = currentDetailLocale()
|
||||||
|
val accent = pastelize(instance.color, dark)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
|
) {
|
||||||
|
// Title row: title on the left, a "Free" pill pinned top-right when the
|
||||||
|
// event doesn't block your time. Busy is the default for nearly every
|
||||||
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
|
// cancelled event strikes through its title.
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(
|
||||||
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (detail.availability == Availability.Free) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
InfoChip(
|
||||||
|
text = stringResource(R.string.event_availability_free),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status / access chips — shown only when noteworthy (Confirmed status
|
||||||
|
// and Default/Public access are the silent norm).
|
||||||
|
val hasStatusChips = detail.status != EventStatus.Confirmed ||
|
||||||
|
detail.accessLevel == AccessLevel.Private ||
|
||||||
|
detail.accessLevel == AccessLevel.Confidential
|
||||||
|
if (hasStatusChips) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
StatusChips(detail.status, detail.accessLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Every piece of info shares one card design: a tonal container with a
|
||||||
|
// leading icon in the gutter and the value to the right. 12dp gaps stack
|
||||||
|
// them cleanly.
|
||||||
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// "When" — date/all-day plus the time range.
|
||||||
|
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||||
|
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||||
|
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (whenSecondary != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = whenSecondary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
|
// than the device's, so cross-zone events read unambiguously.
|
||||||
|
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
|
) {
|
||||||
|
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
|
// separate colour dot is needed.
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.CalendarMonth,
|
||||||
|
iconTint = accent,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location (conditional, tap → maps).
|
||||||
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Place,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = location,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { openInMaps(context, location) }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (conditional). URLs are auto-linked.
|
||||||
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendees (conditional). The user's own response leads the list, then
|
||||||
|
// each attendee with their role and reply.
|
||||||
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.People,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||||
|
) {
|
||||||
|
if (detail.selfStatus != AttendeeStatus.Unknown) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_detail_self_response,
|
||||||
|
stringResource(attendeeStatusLabel(detail.selfStatus)),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
attendees.forEach { AttendeeRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminders (conditional) — list each lead time before the event.
|
||||||
|
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Notifications,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||||
|
) {
|
||||||
|
reminders
|
||||||
|
.distinctBy { it.minutes }
|
||||||
|
.sortedBy { it.minutes }
|
||||||
|
.forEach { reminder ->
|
||||||
|
Text(
|
||||||
|
text = reminderLeadText(reminder),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Repeat,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = recurrenceText(rrule, locale),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||||
|
@Composable
|
||||||
|
private fun DetailCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconContentDescription: String?,
|
||||||
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = iconContentDescription,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttendeeRow(attendee: Attendee) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 3.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(roleRes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status / access pills shown directly under the title accent. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun StatusChips(status: EventStatus, accessLevel: AccessLevel) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
EventStatus.Cancelled -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_cancelled),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Tentative -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_tentative),
|
||||||
|
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Confirmed -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
when (accessLevel) {
|
||||||
|
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
|
||||||
|
AccessLevel.Confidential ->
|
||||||
|
InfoChip(text = stringResource(R.string.event_access_confidential))
|
||||||
|
AccessLevel.Default, AccessLevel.Public -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||||
|
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SkeletonBar(widthFraction = 1f, height = 64.dp)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(widthFraction)
|
||||||
|
.height(height)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Observable locale read (shared helper) — avoids NonObservableLocale /
|
||||||
|
// LocalContextConfigurationRead lint by going through LocalConfiguration.
|
||||||
|
@Composable
|
||||||
|
private fun currentDetailLocale(): Locale = currentLocale()
|
||||||
|
|
||||||
|
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||||
|
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||||
|
AttendeeStatus.Declined -> R.string.event_attendee_declined
|
||||||
|
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
|
||||||
|
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
|
||||||
|
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role badge shown under an attendee's name. Organizer wins over type;
|
||||||
|
* required attendees (the common case) get no badge to keep the list quiet.
|
||||||
|
*/
|
||||||
|
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||||
|
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
|
||||||
|
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
|
||||||
|
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
|
* but only when the event is timed and pinned to a zone different from the
|
||||||
|
* device's. Returns null when there's nothing worth showing.
|
||||||
|
*/
|
||||||
|
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||||
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
|
val deviceZone = ZoneId.systemDefault().id
|
||||||
|
if (tz == deviceZone) return null
|
||||||
|
return try {
|
||||||
|
val zone = ZoneId.of(tz)
|
||||||
|
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
if (name == tz) tz else "$name ($tz)"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
@Composable
|
||||||
|
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
||||||
|
val regex = Regex("""https?://\S+""")
|
||||||
|
val styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||||
|
)
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
for (match in regex.findAll(text)) {
|
||||||
|
// Trim trailing punctuation that commonly abuts a URL in prose.
|
||||||
|
val raw = match.value
|
||||||
|
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
||||||
|
val end = match.range.first + url.length
|
||||||
|
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||||
|
* monthly/yearly BYDAY, etc.).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val freq = parts["FREQ"]?.uppercase()
|
||||||
|
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||||
|
val base = when (freq) {
|
||||||
|
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||||
|
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||||
|
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||||
|
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||||
|
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||||
|
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||||
|
// The day names + their joined block are tracked so only the names (not the
|
||||||
|
// commas/conjunction) can be italicised in the final string.
|
||||||
|
val byDay = parts["BYDAY"]
|
||||||
|
var dayNames: List<String>? = null
|
||||||
|
var joinedDays: String? = null
|
||||||
|
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||||
|
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||||
|
if (days.isNotEmpty()) {
|
||||||
|
val joined = ListFormatter.getInstance(locale).format(days)
|
||||||
|
dayNames = days
|
||||||
|
joinedDays = joined
|
||||||
|
stringResource(R.string.recurrence_on_days, base, joined)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||||
|
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||||
|
val count = parts["COUNT"]?.toIntOrNull()
|
||||||
|
val full = when {
|
||||||
|
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||||
|
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||||
|
else -> main
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(full)
|
||||||
|
val names = dayNames
|
||||||
|
val joined = joinedDays
|
||||||
|
if (names != null && joined != null) {
|
||||||
|
// Italicise each day name within the joined block only — leaving the
|
||||||
|
// separators and conjunction ("und"/"and") in the regular style.
|
||||||
|
val regionStart = full.indexOf(joined)
|
||||||
|
if (regionStart >= 0) {
|
||||||
|
val regionEnd = regionStart + joined.length
|
||||||
|
var cursor = regionStart
|
||||||
|
for (name in names) {
|
||||||
|
val at = full.indexOf(name, cursor)
|
||||||
|
if (at in regionStart until regionEnd) {
|
||||||
|
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||||
|
cursor = at + name.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
"MO" -> DayOfWeek.MONDAY
|
||||||
|
"TU" -> DayOfWeek.TUESDAY
|
||||||
|
"WE" -> DayOfWeek.WEDNESDAY
|
||||||
|
"TH" -> DayOfWeek.THURSDAY
|
||||||
|
"FR" -> DayOfWeek.FRIDAY
|
||||||
|
"SA" -> DayOfWeek.SATURDAY
|
||||||
|
"SU" -> DayOfWeek.SUNDAY
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
|
val digits = raw.takeWhile { it.isDigit() }
|
||||||
|
if (digits.length < 8) return null
|
||||||
|
return try {
|
||||||
|
val date = java.time.LocalDate.of(
|
||||||
|
digits.substring(0, 4).toInt(),
|
||||||
|
digits.substring(4, 6).toInt(),
|
||||||
|
digits.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an event's time into a primary line (date, or "All day") and an
|
||||||
|
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||||
|
* single primary line spanning both ends.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatWhen(
|
||||||
|
instance: EventInstance,
|
||||||
|
zone: TimeZone,
|
||||||
|
locale: Locale,
|
||||||
|
): Pair<String, String?> {
|
||||||
|
val zid = ZoneId.of(zone.id)
|
||||||
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
|
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
|
||||||
|
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
|
if (instance.isAllDay) {
|
||||||
|
// All-day end is the exclusive next midnight; step back to the last
|
||||||
|
// covered day so a one-day event reads as a single date.
|
||||||
|
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||||
|
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||||
|
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||||
|
} else {
|
||||||
|
allDayLabel to
|
||||||
|
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||||
|
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||||
|
dateFull.format(startLdt.toLocalDate()) to
|
||||||
|
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||||
|
} else {
|
||||||
|
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||||
|
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||||
|
"$start – $end" to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||||
|
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
||||||
|
|
||||||
|
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||||
|
private fun openInMaps(context: Context, query: String) {
|
||||||
|
val encoded = Uri.encode(query)
|
||||||
|
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||||
|
try {
|
||||||
|
context.startActivity(geo)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
val web = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
context.startActivity(web)
|
||||||
|
} catch (e2: ActivityNotFoundException) {
|
||||||
|
// No browser either — nothing sensible to do; swallow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the event-detail screen (spec S4).
|
||||||
|
*/
|
||||||
|
sealed interface EventDetailUiState {
|
||||||
|
data object Loading : EventDetailUiState
|
||||||
|
data class Failure(val reason: FailureReason) : EventDetailUiState
|
||||||
|
data class Success(
|
||||||
|
val detail: EventDetail,
|
||||||
|
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||||
|
val calendarName: String?,
|
||||||
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
|
val canModify: Boolean = false,
|
||||||
|
) : EventDetailUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot state of a delete request, separate from the screen state so a
|
||||||
|
* failed delete leaves the loaded detail visible.
|
||||||
|
*/
|
||||||
|
sealed interface DeleteUiState {
|
||||||
|
data object Idle : DeleteUiState
|
||||||
|
data object Deleting : DeleteUiState
|
||||||
|
data object Deleted : DeleteUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : DeleteUiState
|
||||||
|
data object Failed : DeleteUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.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.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a single event's detail on demand for the bottom sheet (spec S4).
|
||||||
|
* The event id is set via [open]; the sheet observes [state].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class EventDetailViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
|
// Bumped by retry() to re-run the load for the same target.
|
||||||
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<EventDetailUiState> =
|
||||||
|
combine(_target, _reload) { target, _ -> target }
|
||||||
|
.flatMapLatest { target ->
|
||||||
|
if (target == null) {
|
||||||
|
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||||
|
} else {
|
||||||
|
flow {
|
||||||
|
emit(EventDetailUiState.Loading)
|
||||||
|
emit(loadDetail(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = EventDetailUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
|
||||||
|
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||||
|
* override the series DTSTART/DTEND so recurring events show the correct
|
||||||
|
* date instead of the first occurrence.
|
||||||
|
*/
|
||||||
|
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
|
_target.value = Target(eventId, beginMillis, endMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-run the current load after a failure. */
|
||||||
|
fun retry() {
|
||||||
|
_reload.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the open event. [wholeSeries] is meaningful only for recurring
|
||||||
|
* events: false cancels just the tapped occurrence. Result lands in
|
||||||
|
* [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||||
|
*/
|
||||||
|
fun delete(wholeSeries: Boolean) {
|
||||||
|
val target = _target.value ?: return
|
||||||
|
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
|
_deleteState.value = try {
|
||||||
|
if (wholeSeries) {
|
||||||
|
repository.deleteEvent(target.eventId)
|
||||||
|
} else {
|
||||||
|
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||||
|
}
|
||||||
|
DeleteUiState.Deleted
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
DeleteUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DeleteUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen handled a terminal result. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = DeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
|
val detail = repository.eventDetail(target.eventId)
|
||||||
|
// The Events row holds the series start; replace it with this
|
||||||
|
// occurrence's time so recurring events render correctly.
|
||||||
|
val corrected = detail.copy(
|
||||||
|
instance = detail.instance.copy(
|
||||||
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val calendar = repository.calendars().first()
|
||||||
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
|
EventDetailUiState.Success(
|
||||||
|
detail = corrected,
|
||||||
|
calendarName = calendar?.displayName,
|
||||||
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.EventNotFound)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
|
||||||
|
* form means the screen hasn't been opened yet.
|
||||||
|
*/
|
||||||
|
data class EventEditUiState(
|
||||||
|
/** The form with its calendar id resolved (picked > last used > first writable). */
|
||||||
|
val form: EventForm,
|
||||||
|
/** Calendars that accept writes — the only valid targets. */
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
/** Validation problems; empty until a save was attempted. */
|
||||||
|
val problems: Set<EventFormProblem>,
|
||||||
|
val saveState: SaveUiState,
|
||||||
|
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
||||||
|
val visibleFields: Set<EventFormField> = emptySet(),
|
||||||
|
/** True while at least one optional section hides behind "more fields". */
|
||||||
|
val hasHiddenFields: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
sealed interface SaveUiState {
|
||||||
|
data object Idle : SaveUiState
|
||||||
|
data object Saving : SaveUiState
|
||||||
|
data object Saved : SaveUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : SaveUiState
|
||||||
|
data object Failed : SaveUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
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.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
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.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
|
* shows and what gets saved.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventEditViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _form = MutableStateFlow<EventForm?>(null)
|
||||||
|
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
||||||
|
// Problems stay hidden until the first save attempt, so a half-filled
|
||||||
|
// form isn't already shouting errors.
|
||||||
|
private val _showProblems = MutableStateFlow(false)
|
||||||
|
// Fields added through the "more fields" picker; folds back on reset().
|
||||||
|
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
||||||
|
|
||||||
|
private data class LocalInputs(
|
||||||
|
val form: EventForm?,
|
||||||
|
val saveState: SaveUiState,
|
||||||
|
val showProblems: Boolean,
|
||||||
|
val revealed: Set<EventFormField>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ExternalInputs(
|
||||||
|
val writable: List<CalendarSource>,
|
||||||
|
val lastUsed: Long?,
|
||||||
|
val defaultFields: Set<EventFormField>,
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
|
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
|
||||||
|
combine(
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) },
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
settingsPrefs.defaultFormFields,
|
||||||
|
::ExternalInputs,
|
||||||
|
),
|
||||||
|
) { local, external ->
|
||||||
|
val form = local.form ?: return@combine null
|
||||||
|
val resolvedId = form.calendarId
|
||||||
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
|
?: external.writable.firstOrNull()?.id
|
||||||
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
|
EventEditUiState(
|
||||||
|
form = resolved,
|
||||||
|
calendars = external.writable,
|
||||||
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
|
saveState = local.saveState,
|
||||||
|
visibleFields = visibleFields,
|
||||||
|
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise a fresh form for a new event on [date]. No-op when a form is
|
||||||
|
* already open, so user input survives configuration changes; [reset]
|
||||||
|
* clears it when the screen closes.
|
||||||
|
*/
|
||||||
|
fun openNew(date: LocalDate) {
|
||||||
|
if (_form.value != null) return
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val start = if (date == now.toLocalDateTime(zone).date) {
|
||||||
|
// Today: the next full hour (may roll into tomorrow before midnight).
|
||||||
|
val hourMillis = 3_600_000L
|
||||||
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
|
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
|
} else {
|
||||||
|
LocalDateTime(date, LocalTime(9, 0))
|
||||||
|
}
|
||||||
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the open form; the next [openNew] starts clean. */
|
||||||
|
fun reset() {
|
||||||
|
_form.value = null
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
_showProblems.value = false
|
||||||
|
_revealed.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
|
fun revealField(field: EventFormField) {
|
||||||
|
_revealed.value = _revealed.value + field
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setCalendar(id: Long) = update { it.copy(calendarId = id) }
|
||||||
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
|
fun addReminder(minutes: Int) = update {
|
||||||
|
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeReminder(minutes: Int) = update {
|
||||||
|
it.copy(reminders = it.reminders - minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
|
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
|
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
|
||||||
|
/** Validate and write. Terminal results land in [saveState]. */
|
||||||
|
fun save() {
|
||||||
|
val current = state.value ?: return
|
||||||
|
if (current.saveState == SaveUiState.Saving) return
|
||||||
|
val form = current.form
|
||||||
|
if (form.problems().isNotEmpty()) {
|
||||||
|
_showProblems.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_saveState.value = SaveUiState.Saving
|
||||||
|
_saveState.value = try {
|
||||||
|
repository.createEvent(form)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
SaveUiState.Saved
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
SaveUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SaveUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [saveState] after the screen handled a terminal result. */
|
||||||
|
fun consumeSaveResult() {
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val newStart = transform(form.start)
|
||||||
|
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
|
||||||
|
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
|
||||||
|
form.copy(start = newStart, end = newEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun update(block: (EventForm) -> EventForm) {
|
||||||
|
_form.value = _form.value?.let(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
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.Row
|
||||||
|
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.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
* Every calendar grouped by account, each with a colour swatch and a visibility
|
||||||
|
* switch; toggling writes straight to DataStore and every calendar view
|
||||||
|
* re-filters live. Three states (Loading / Failure / Success).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarFilterList(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: FilterViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
when (val s = state) {
|
||||||
|
FilterUiState.Loading -> FilterLoading(modifier)
|
||||||
|
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
||||||
|
is FilterUiState.Success -> FilterList(
|
||||||
|
groups = s.groups,
|
||||||
|
onSetVisible = viewModel::setVisible,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterList(
|
||||||
|
groups: List<AccountGroup>,
|
||||||
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "header-${group.account}") {
|
||||||
|
Text(
|
||||||
|
text = group.account,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.calendars, key = { it.id }) { cal ->
|
||||||
|
CalendarToggleRow(
|
||||||
|
row = cal,
|
||||||
|
dark = dark,
|
||||||
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarToggleRow(
|
||||||
|
row: CalendarRow,
|
||||||
|
dark: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.background(pastelize(row.color, dark), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = row.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = row.visible,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
repeat(4) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 28.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
MaterialTheme.shapes.medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||||
|
val msg = when (reason) {
|
||||||
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
|
else -> R.string.state_failure_provider
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(msg),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||||
|
* visibility; the choice is persisted app-side (separate from the system's
|
||||||
|
* VISIBLE flag) and applied to every calendar view.
|
||||||
|
*/
|
||||||
|
sealed interface FilterUiState {
|
||||||
|
data object Loading : FilterUiState
|
||||||
|
data class Failure(val reason: FailureReason) : FilterUiState
|
||||||
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||||
|
data class AccountGroup(
|
||||||
|
val account: String,
|
||||||
|
val calendars: List<CalendarRow>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarRow(
|
||||||
|
val id: Long,
|
||||||
|
val displayName: String,
|
||||||
|
val color: Int,
|
||||||
|
val visible: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
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.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FilterViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val state: StateFlow<FilterUiState> =
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { calendars, hidden ->
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
} else {
|
||||||
|
FilterUiState.Success(groupByAccount(calendars, hidden))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = FilterUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Show or hide a single calendar; persists the new hidden set. */
|
||||||
|
fun setVisible(calendarId: Long, visible: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = prefs.hiddenCalendarIds.first()
|
||||||
|
val next = if (visible) current - calendarId else current + calendarId
|
||||||
|
if (next != current) prefs.setHiddenCalendarIds(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group calendars under their owning account, preserving the provider's order
|
||||||
|
* within each group and ordering groups by first appearance. A calendar is
|
||||||
|
* "visible" when its id is *not* in [hidden].
|
||||||
|
*/
|
||||||
|
internal fun groupByAccount(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
hidden: Set<Long>,
|
||||||
|
): List<AccountGroup> =
|
||||||
|
calendars
|
||||||
|
.groupBy { it.accountLabel() }
|
||||||
|
.map { (account, cals) ->
|
||||||
|
AccountGroup(
|
||||||
|
account = account,
|
||||||
|
calendars = cals.map { c ->
|
||||||
|
CalendarRow(
|
||||||
|
id = c.id,
|
||||||
|
displayName = c.displayName,
|
||||||
|
color = c.color,
|
||||||
|
visible = c.id !in hidden,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Account header text: the account name, falling back to its type. */
|
||||||
|
private fun CalendarSource.accountLabel(): String =
|
||||||
|
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
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.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.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.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MonthScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isOnCurrentMonth = when (val s = state) {
|
||||||
|
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val goNext = {
|
||||||
|
slideDir = 1
|
||||||
|
viewModel.goToNext()
|
||||||
|
}
|
||||||
|
val goPrev = {
|
||||||
|
slideDir = -1
|
||||||
|
viewModel.goToPrev()
|
||||||
|
}
|
||||||
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
|
val jumpToToday = {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is MonthUiState.Success ->
|
||||||
|
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
// Open only via the menu button — edge-swipe would fight the month swipe.
|
||||||
|
gesturesEnabled = drawerState.isOpen,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
onToday = {
|
||||||
|
jumpToToday()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
MonthTopBar(
|
||||||
|
month = month,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnCurrentMonth,
|
||||||
|
todayText = stringResource(R.string.month_today_action),
|
||||||
|
onToday = jumpToToday,
|
||||||
|
onCreate = {
|
||||||
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
|
val today = Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
onCreateEvent(
|
||||||
|
if (isOnCurrentMonth) today
|
||||||
|
else LocalDate(month.year, month.month, 1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
WeekdayHeader(weekStart = weekStart)
|
||||||
|
MonthContent(
|
||||||
|
state = state,
|
||||||
|
weekStart = weekStart,
|
||||||
|
slideDir = slideDir,
|
||||||
|
onSwipeNext = goNext,
|
||||||
|
onSwipePrev = goPrev,
|
||||||
|
onRetry = jumpToToday,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthContent(
|
||||||
|
state: MonthUiState,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
slideDir: Int,
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val threshold = with(density) { 6.dp.toPx() }
|
||||||
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { dragAccum = 0f },
|
||||||
|
onDragEnd = {
|
||||||
|
when {
|
||||||
|
dragAccum < -threshold -> onSwipeNext()
|
||||||
|
dragAccum > threshold -> onSwipePrev()
|
||||||
|
}
|
||||||
|
dragAccum = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { dragAccum = 0f },
|
||||||
|
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||||
|
contentKey = { s ->
|
||||||
|
when (s) {
|
||||||
|
is MonthUiState.Success -> "success-${s.month}"
|
||||||
|
is MonthUiState.Failure -> "failure-${s.reason}"
|
||||||
|
MonthUiState.Loading -> "loading"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||||
|
label = "month-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
MonthUiState.Loading -> MonthGridLoading()
|
||||||
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is MonthUiState.Success -> MonthGrid(
|
||||||
|
state = s,
|
||||||
|
weekStart = weekStart,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MonthTopBar(
|
||||||
|
month: YearMonth,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = formatMonthYear(month),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val days = remember(weekStart, locale) {
|
||||||
|
(0 until 7).map { offset ->
|
||||||
|
DayOfWeek.entries[((weekStart.ordinal + offset) % 7)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
days.forEach { dow ->
|
||||||
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
|
Text(
|
||||||
|
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthGrid(
|
||||||
|
state: MonthUiState.Success,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
||||||
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
|
|
||||||
|
// Show only the weeks the current month actually touches; leading/trailing
|
||||||
|
// days of neighbouring months are left blank rather than rendered.
|
||||||
|
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth =
|
||||||
|
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
|
||||||
|
val weeks = (leadOffset + daysInMonth + 6) / 7
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
repeat(weeks) { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
repeat(7) { col ->
|
||||||
|
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
|
||||||
|
val inMonth =
|
||||||
|
date.month == state.month.month && date.year == state.month.year
|
||||||
|
if (inMonth) {
|
||||||
|
DayCard(
|
||||||
|
date = date,
|
||||||
|
isToday = date == state.today,
|
||||||
|
data = state.cells[date],
|
||||||
|
onClick = { onOpenDay(date) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun DayCard(
|
||||||
|
date: LocalDate,
|
||||||
|
isToday: Boolean,
|
||||||
|
data: DayCellData?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||||
|
val cellLabel = buildString {
|
||||||
|
if (isToday) append(todayPrefix).append(", ")
|
||||||
|
append(date.year).append('-')
|
||||||
|
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
|
||||||
|
append(date.day.toString().padStart(2, '0'))
|
||||||
|
data?.let { append(", ").append(it.count).append(" Events") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3 Expressive press feedback: a spatial spring from the active motion
|
||||||
|
// scheme drives a subtle scale, instead of a fixed easing curve.
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (pressed) 0.94f else 1f,
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
label = "day-card-press",
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.semantics { contentDescription = cellLabel },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 4.dp, bottom = 2.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
EventDotRow(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventDotRow(data: DayCellData?) {
|
||||||
|
if (data == null || data.swatches.isEmpty()) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
data.swatches.forEach { argb ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.background(pastelize(argb, dark), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (data.count > data.swatches.size) {
|
||||||
|
Text(
|
||||||
|
text = "+${data.count - data.swatches.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthGridLoading() {
|
||||||
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
repeat(6) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
repeat(7) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = shape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMonthYear(ym: YearMonth): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val name = java.time.Month.of(ym.month.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
return "$name ${ym.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-day aggregation surfaced to the month grid. We only need
|
||||||
|
* - the total event count (drives the optional "+N" indicator), and
|
||||||
|
* - up to three calendar colors for the dot row.
|
||||||
|
*
|
||||||
|
* The day cell never holds full event objects — the detail sheet pulls those
|
||||||
|
* lazily.
|
||||||
|
*/
|
||||||
|
data class DayCellData(
|
||||||
|
val count: Int,
|
||||||
|
val swatches: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface MonthUiState {
|
||||||
|
data object Loading : MonthUiState
|
||||||
|
data class Failure(val reason: FailureReason) : MonthUiState
|
||||||
|
data class Success(
|
||||||
|
val month: YearMonth,
|
||||||
|
val today: LocalDate,
|
||||||
|
val cells: Map<LocalDate, DayCellData>,
|
||||||
|
) : MonthUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
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.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
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.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
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
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class MonthViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
private val locale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||||
|
.map { it.resolveFirstDay(locale) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = DayOfWeek.MONDAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val todayDate: LocalDate
|
||||||
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
|
val state: StateFlow<MonthUiState> =
|
||||||
|
combine(_month, weekStart) { ym, ws -> ym to ws }
|
||||||
|
.flatMapLatest { (ym, ws) ->
|
||||||
|
val range = monthGridRange(ym, ws, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(ym, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = MonthUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToPrev() {
|
||||||
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToNext() {
|
||||||
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
ym: YearMonth,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): MonthUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
|
||||||
|
.mapValues { (_, evs) ->
|
||||||
|
DayCellData(
|
||||||
|
count = evs.size,
|
||||||
|
swatches = evs.map { it.color }.distinct().take(3),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return MonthUiState.Success(
|
||||||
|
month = ym,
|
||||||
|
today = todayDate,
|
||||||
|
cells = byDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||||
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
|
*/
|
||||||
|
internal fun monthGridRange(
|
||||||
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): ClosedRange<Instant> {
|
||||||
|
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||||
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
|
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
|
||||||
|
val start = gridStart.atStartOfDayIn(zone)
|
||||||
|
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
return minus(offset, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.READ_CALENDAR,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD3 8dp spacing scale, scoped to this screen.
|
||||||
|
private object Space {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PermissionScreen(
|
||||||
|
onGranted: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PermissionViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// READ and WRITE are requested together (one system dialog — same
|
||||||
|
// permission group), but only READ gates the app: declining write keeps
|
||||||
|
// Calendula usable read-only.
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { results ->
|
||||||
|
if (results[Manifest.permission.READ_CALENDAR] == true) {
|
||||||
|
viewModel.onGranted()
|
||||||
|
} else {
|
||||||
|
viewModel.onDenied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state == PermissionUiState.Granted) onGranted()
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
|
onRetry = {
|
||||||
|
viewModel.onRetry()
|
||||||
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
is PermissionUiState.Granted -> {
|
||||||
|
// Transient — LaunchedEffect above fires and parent replaces us.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RationaleContent(
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
PermissionScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BrandHero(denied = false) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onRequest,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_request_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(Space.xs))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PrivacyFootnote()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_rationale_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CalendarMonth,
|
||||||
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeniedContent(
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
PermissionScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BrandHero(denied = true) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_open_settings_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.permission_retry_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
||||||
|
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
||||||
|
* centred horizontally; benefit rows fill the width so their own content
|
||||||
|
* left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PermissionScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = Space.md, vertical = Space.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = Space.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(Space.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(Space.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
private fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(Space.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyFootnote() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_privacy_footnote),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
sealed interface PermissionUiState {
|
||||||
|
data object Rationale : PermissionUiState
|
||||||
|
data object Denied : PermissionUiState
|
||||||
|
data object Granted : PermissionUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PermissionViewModel @Inject constructor() : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||||
|
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onGranted() {
|
||||||
|
_state.value = PermissionUiState.Granted
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDenied() {
|
||||||
|
_state.value = PermissionUiState.Denied
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRetry() {
|
||||||
|
_state.value = PermissionUiState.Rationale
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
|
||||||
|
/** UI-facing language choice. AUTO follows the system languages. */
|
||||||
|
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(pref: LanguagePref) {
|
||||||
|
val locales = when (pref) {
|
||||||
|
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
||||||
|
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
||||||
|
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.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.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
|
||||||
|
* and an about section. A full-screen destination; [onBack] pops it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Intercept the system back button/gesture — without this it falls through
|
||||||
|
// to the activity and closes the app instead of returning to the calendar.
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_appearance))
|
||||||
|
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
selected = state.themeMode,
|
||||||
|
options = ThemeMode.entries,
|
||||||
|
optionLabel = { themeLabel(it) },
|
||||||
|
onSelect = viewModel::setThemeMode,
|
||||||
|
)
|
||||||
|
DynamicColorRow(
|
||||||
|
checked = state.dynamicColor,
|
||||||
|
enabled = state.dynamicColorAvailable,
|
||||||
|
onCheckedChange = viewModel::setDynamicColor,
|
||||||
|
)
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
selected = state.weekStart,
|
||||||
|
options = WeekStartPref.entries,
|
||||||
|
optionLabel = { weekStartLabel(it) },
|
||||||
|
onSelect = viewModel::setWeekStart,
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_event_form))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_form_fields_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
EventFormField.entries.forEach { field ->
|
||||||
|
FormFieldRow(
|
||||||
|
title = stringResource(formFieldLabel(field)),
|
||||||
|
checked = field in state.defaultFormFields,
|
||||||
|
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_language))
|
||||||
|
LanguageRow()
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_about))
|
||||||
|
AboutSection()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageRow() {
|
||||||
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
|
// dropdown updates instantly even before the recreation lands.
|
||||||
|
var current by remember { mutableStateOf(AppLanguage.current()) }
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
selected = current,
|
||||||
|
options = LanguagePref.entries,
|
||||||
|
optionLabel = { languageLabel(it) },
|
||||||
|
onSelect = {
|
||||||
|
current = it
|
||||||
|
AppLanguage.apply(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> SettingDropdownRow(
|
||||||
|
title: String,
|
||||||
|
selected: T,
|
||||||
|
options: List<T>,
|
||||||
|
optionLabel: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = true }
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = optionLabel(selected),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel(option)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(option)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DynamicColorRow(
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_dynamic_color),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (enabled) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (!enabled) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_dynamic_color_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutSection() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val versionName = remember {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull() ?: "—"
|
||||||
|
}
|
||||||
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
|
|
||||||
|
AboutRow(
|
||||||
|
title = stringResource(R.string.settings_version),
|
||||||
|
value = versionName,
|
||||||
|
)
|
||||||
|
AboutRow(
|
||||||
|
title = stringResource(R.string.settings_license),
|
||||||
|
value = stringResource(R.string.settings_license_value),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f).padding(start = 8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_source),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sourceUrl.removePrefix("https://"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.settings_source_open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutRow(title: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FormFieldRow(
|
||||||
|
title: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
|
when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
|
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||||
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
||||||
|
when (pref) {
|
||||||
|
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
||||||
|
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
||||||
|
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@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
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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.EventFormField
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
|
* there is no Loading/Failure here — only a populated Success snapshot.
|
||||||
|
* [dynamicColorAvailable] is false below Android 12, where the toggle is shown
|
||||||
|
* disabled.
|
||||||
|
*/
|
||||||
|
data class SettingsUiState(
|
||||||
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
|
val dynamicColor: Boolean = true,
|
||||||
|
val dynamicColorAvailable: Boolean = true,
|
||||||
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
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.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
val state: StateFlow<SettingsUiState> =
|
||||||
|
combine(
|
||||||
|
prefs.themeMode,
|
||||||
|
prefs.dynamicColor,
|
||||||
|
prefs.weekStart,
|
||||||
|
prefs.defaultFormFields,
|
||||||
|
) { theme, dynamic, weekStart, formFields ->
|
||||||
|
SettingsUiState(
|
||||||
|
themeMode = theme,
|
||||||
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
|
weekStart = weekStart,
|
||||||
|
defaultFormFields = formFields,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDynamicColor(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||||
* but the V1 foundation just follows the system.
|
* but the V1 foundation just follows the system.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
@@ -32,9 +35,15 @@ fun CalendulaTheme(
|
|||||||
else -> CalendulaLightFallback
|
else -> CalendulaLightFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
// MaterialExpressiveTheme routes all component + custom motion through
|
||||||
|
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
||||||
|
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
||||||
|
// expressive(): same spring choreography, but without the overshoot —
|
||||||
|
// the bouncy variant felt overdone in review (2026-06-11).
|
||||||
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = CalendulaTypography,
|
typography = CalendulaTypography,
|
||||||
|
motionScheme = MotionScheme.standard(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
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.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
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.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val HOUR_HEIGHT = 56.dp
|
||||||
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
|
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
|
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||||
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WeekScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// The static header + all-day strip share the app bar's scrolled colour so
|
||||||
|
// the whole top region elevates together once the timeline scrolls under it.
|
||||||
|
val topSectionColor by animateColorAsState(
|
||||||
|
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "week-top-section-color",
|
||||||
|
)
|
||||||
|
|
||||||
|
val isOnCurrentWeek = when (val s = state) {
|
||||||
|
// True when today falls inside the displayed week — independent of which
|
||||||
|
// weekday the user picked as the first day.
|
||||||
|
is WeekUiState.Success ->
|
||||||
|
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
|
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||||
|
// Slide toward today: viewing the future → today comes in from the left
|
||||||
|
// (back), viewing the past → from the right (forward).
|
||||||
|
val jumpToToday = {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
// Open only via the menu button — edge-swipe would fight the week swipe.
|
||||||
|
gesturesEnabled = drawerState.isOpen,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
WeekTopBar(
|
||||||
|
weekStart = weekStart,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnCurrentWeek,
|
||||||
|
todayText = stringResource(R.string.week_today_action),
|
||||||
|
onToday = jumpToToday,
|
||||||
|
onCreate = {
|
||||||
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
|
val today = Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
WeekContent(
|
||||||
|
state = state,
|
||||||
|
slideDir = slideDir,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
onSwipeNext = goNext,
|
||||||
|
onSwipePrev = goPrev,
|
||||||
|
onRetry = jumpToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekContent(
|
||||||
|
state: WeekUiState,
|
||||||
|
slideDir: Int,
|
||||||
|
topSectionColor: Color,
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||||
|
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||||
|
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||||
|
// month/day view), not on every swipe.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
val maxV = scrollState.maxValue
|
||||||
|
val target = with(density) {
|
||||||
|
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||||
|
}.coerceIn(0, maxV)
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
|
// week during a swipe, so the strip slides along but never jumps in height;
|
||||||
|
// it just springs smoothly from the old to the new size.
|
||||||
|
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||||
|
val allDayHeight by animateDpAsState(
|
||||||
|
targetValue = targetAllDayHeight,
|
||||||
|
label = "all-day-strip-height",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||||
|
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||||
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
|
// gestures coexist without fighting.
|
||||||
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { dragAccum = 0f },
|
||||||
|
onDragEnd = {
|
||||||
|
when {
|
||||||
|
dragAccum < -threshold -> onSwipeNext()
|
||||||
|
dragAccum > threshold -> onSwipePrev()
|
||||||
|
}
|
||||||
|
dragAccum = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { dragAccum = 0f },
|
||||||
|
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
modifier = modifier.then(swipeModifier),
|
||||||
|
contentKey = { s ->
|
||||||
|
when (s) {
|
||||||
|
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||||
|
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||||
|
WeekUiState.Loading -> "loading"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||||
|
label = "week-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
WeekUiState.Loading -> WeekLoading()
|
||||||
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekSuccess(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
topSectionColor: Color,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(topSectionColor),
|
||||||
|
) {
|
||||||
|
WeekDayHeader(days = state.days, today = state.today)
|
||||||
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
|
}
|
||||||
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
|
// scrolling timeline below.
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun WeekTopBar(
|
||||||
|
weekStart: LocalDate,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = formatWeekRange(weekStart),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Match the static top section exactly: plain surface, lifting to
|
||||||
|
// surfaceContainer once content scrolls under the bar.
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val weekStart = days.first()
|
||||||
|
val weekNumber = remember(weekStart) {
|
||||||
|
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
||||||
|
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
|
// badge lines up vertically with the date numbers.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.width(GUTTER_WIDTH),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
WeekNumberBadge(weekNumber = weekNumber)
|
||||||
|
}
|
||||||
|
days.forEach { date ->
|
||||||
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
|
val isToday = date == today
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
// Always reserve the 28dp circle slot so the header height is
|
||||||
|
// identical whether or not the week contains today.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isToday) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||||
|
* filled box and bold number. */
|
||||||
|
@Composable
|
||||||
|
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||||
|
val label = stringResource(R.string.week_number_label)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekNumber.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AllDayStrip(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
height: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||||
|
// padding sits inside it so the content area is lanes * row height.
|
||||||
|
.height(height)
|
||||||
|
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||||
|
) {
|
||||||
|
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
// Span bars are positioned absolutely so a multi-day event is one
|
||||||
|
// connected bar across columns rather than a chip per day. clipToBounds
|
||||||
|
// keeps bars from spilling out while the height animates.
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
val colWidth = maxWidth / 7
|
||||||
|
state.allDaySpans.forEach { span ->
|
||||||
|
val spanCols = span.endCol - span.startCol + 1
|
||||||
|
AllDayBar(
|
||||||
|
event = span.event,
|
||||||
|
dark = dark,
|
||||||
|
onClick = { onEventClick(span.event) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
x = colWidth * span.startCol,
|
||||||
|
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||||
|
)
|
||||||
|
.width(colWidth * spanCols)
|
||||||
|
.height(ALL_DAY_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AllDayBar(
|
||||||
|
event: EventInstance,
|
||||||
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
.semantics { contentDescription = title },
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Timeline(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
|
// day's start/end).
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Hour gutter (scrolls in sync with the day columns)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(GUTTER_WIDTH)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(HOUR_HEIGHT),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = "%02d".format(h),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(totalHeight),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
state.days.forEach { day ->
|
||||||
|
DayColumnCard(
|
||||||
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
|
dark = dark,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayColumnCard(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
dark: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
|
shape = RectangleShape,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val colWidth = maxWidth
|
||||||
|
blocks.forEach { block ->
|
||||||
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||||
|
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||||
|
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||||
|
EventBlock(
|
||||||
|
block = block,
|
||||||
|
dark = dark,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
|
.width(laneWidth)
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventBlock(
|
||||||
|
block: TimedBlock,
|
||||||
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||||
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = if (showTime) 1 else 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
if (showTime) {
|
||||||
|
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||||
|
// wrap to a second line (after the dash) instead of clipping the
|
||||||
|
// end time.
|
||||||
|
Text(
|
||||||
|
text = timeLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekLoading() {
|
||||||
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Header skeleton
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
repeat(7) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
repeat(7) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minToHm(min: Int): String =
|
||||||
|
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||||
|
|
||||||
|
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
val monthName = { d: LocalDate ->
|
||||||
|
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
}
|
||||||
|
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
||||||
|
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
||||||
|
} else if (weekStart.year == end.year) {
|
||||||
|
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
||||||
|
} else {
|
||||||
|
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
||||||
|
"${end.day}. ${monthName(end)} ${end.year}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One timed event clipped to a single day and assigned a horizontal lane so
|
||||||
|
* overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander
|
||||||
|
* aufgelöst").
|
||||||
|
*
|
||||||
|
* @param startMin minutes from this day's midnight, clamped to [0, 1440]
|
||||||
|
* @param endMin minutes from this day's midnight, clamped to [startMin, 1440];
|
||||||
|
* equal to [startMin] for instant events (render enforces a
|
||||||
|
* minimum tap-target height)
|
||||||
|
* @param lane 0-based column within [laneCount]
|
||||||
|
* @param laneCount number of columns the event's overlap-cluster needs
|
||||||
|
*/
|
||||||
|
data class TimedBlock(
|
||||||
|
val event: EventInstance,
|
||||||
|
val startMin: Int,
|
||||||
|
val endMin: Int,
|
||||||
|
val lane: Int,
|
||||||
|
val laneCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An all-day (or multi-day) event laid out as a single horizontal bar spanning
|
||||||
|
* [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping
|
||||||
|
* spans don't collide. A multi-day event is one connected bar — not one chip per
|
||||||
|
* day.
|
||||||
|
*
|
||||||
|
* @param startCol first visible covered column, 0..6 (clamped to the week)
|
||||||
|
* @param endCol last visible covered column, 0..6, inclusive
|
||||||
|
* @param lane 0-based stacking row
|
||||||
|
*/
|
||||||
|
data class AllDaySpan(
|
||||||
|
val event: EventInstance,
|
||||||
|
val startCol: Int,
|
||||||
|
val endCol: Int,
|
||||||
|
val lane: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface WeekUiState {
|
||||||
|
data object Loading : WeekUiState
|
||||||
|
data class Failure(val reason: FailureReason) : WeekUiState
|
||||||
|
data class Success(
|
||||||
|
val weekStart: LocalDate,
|
||||||
|
val today: LocalDate,
|
||||||
|
/** The seven days of the week, [weekStart] first. */
|
||||||
|
val days: List<LocalDate>,
|
||||||
|
/** All-day/multi-day events as connected horizontal spans. */
|
||||||
|
val allDaySpans: List<AllDaySpan>,
|
||||||
|
/** Timed events, clipped to each day with lanes resolved. */
|
||||||
|
val timedByDay: Map<LocalDate, List<TimedBlock>>,
|
||||||
|
) : WeekUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
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.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
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.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
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
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class WeekViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
private val locale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
private val todayDate: LocalDate
|
||||||
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
|
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||||
|
.map { it.resolveFirstDay(locale) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = DayOfWeek.MONDAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anchor is a representative day inside the visible week; the actual week
|
||||||
|
// start is derived against [weekStart], so changing the first-day preference
|
||||||
|
// re-frames the same week instead of jumping.
|
||||||
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
|
||||||
|
val weekStartDate: StateFlow<LocalDate> =
|
||||||
|
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<WeekUiState> =
|
||||||
|
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest { start ->
|
||||||
|
val range = weekRange(start, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(start, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = WeekUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToPrev() {
|
||||||
|
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToNext() {
|
||||||
|
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_anchor.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
start: LocalDate,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): WeekUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||||
|
val allDay = instances.filter { it.isAllDay }
|
||||||
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
|
return WeekUiState.Success(
|
||||||
|
weekStart = start,
|
||||||
|
today = todayDate,
|
||||||
|
days = days,
|
||||||
|
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||||
|
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||||
|
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||||
|
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
||||||
|
*/
|
||||||
|
internal fun layoutAllDay(
|
||||||
|
events: List<EventInstance>,
|
||||||
|
days: List<LocalDate>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<AllDaySpan> {
|
||||||
|
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
|
||||||
|
|
||||||
|
val raw = events
|
||||||
|
.mapNotNull { ev ->
|
||||||
|
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||||
|
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
||||||
|
|
||||||
|
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
||||||
|
return raw.map { r ->
|
||||||
|
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
||||||
|
if (lane == -1) {
|
||||||
|
laneEnd.add(r.endCol)
|
||||||
|
lane = laneEnd.size - 1
|
||||||
|
} else {
|
||||||
|
laneEnd[lane] = r.endCol
|
||||||
|
}
|
||||||
|
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||||
|
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
return minus(offset, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-open instant range covering the seven days starting at [start]. */
|
||||||
|
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||||
|
val from = start.atStartOfDayIn(zone)
|
||||||
|
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return from..to
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||||
|
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||||
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
return start < dayEnd && end > dayStart
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||||
|
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||||
|
* chained-overlapping events), matching the common phone week-view behaviour.
|
||||||
|
*
|
||||||
|
* All-day events are ignored here — they live in the all-day strip.
|
||||||
|
*/
|
||||||
|
internal fun layoutDay(
|
||||||
|
events: List<EventInstance>,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<TimedBlock> {
|
||||||
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
|
||||||
|
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
|
||||||
|
|
||||||
|
val raw = events.asSequence()
|
||||||
|
.filterNot { it.isAllDay }
|
||||||
|
.mapNotNull { ev ->
|
||||||
|
if (ev.start == ev.end) {
|
||||||
|
// Instant event: keep only if the point falls inside this day.
|
||||||
|
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||||
|
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||||
|
Raw(ev, m, m)
|
||||||
|
} else {
|
||||||
|
val s = maxOf(ev.start, dayStart)
|
||||||
|
val e = minOf(ev.end, dayEnd)
|
||||||
|
if (e <= s) return@mapNotNull null
|
||||||
|
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||||
|
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
|
||||||
|
Raw(ev, startMin, endMin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val result = ArrayList<TimedBlock>(raw.size)
|
||||||
|
var i = 0
|
||||||
|
while (i < raw.size) {
|
||||||
|
// Grow a cluster of chained-overlapping events.
|
||||||
|
var clusterEnd = raw[i].endMin
|
||||||
|
var j = i + 1
|
||||||
|
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||||
|
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
val cluster = raw.subList(i, j)
|
||||||
|
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||||
|
val laneEnd = ArrayList<Int>()
|
||||||
|
val lanes = IntArray(cluster.size)
|
||||||
|
cluster.forEachIndexed { k, r ->
|
||||||
|
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||||
|
if (placed == -1) {
|
||||||
|
laneEnd.add(r.endMin)
|
||||||
|
placed = laneEnd.size - 1
|
||||||
|
} else {
|
||||||
|
laneEnd[placed] = r.endMin
|
||||||
|
}
|
||||||
|
lanes[k] = placed
|
||||||
|
}
|
||||||
|
val laneCount = laneEnd.size
|
||||||
|
cluster.forEachIndexed { k, r ->
|
||||||
|
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,16 +1,95 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Calendula launcher icon foreground.
|
||||||
|
|
||||||
|
Converted from design/icon/calendula_mark.svg (232x232 viewport).
|
||||||
|
Composition: rounded line-art calendar with a stylized "1" inside
|
||||||
|
(referencing kalendae, the Latin word for the first day of the month
|
||||||
|
that is the etymological root of both "calendar" and "calendula"),
|
||||||
|
plus a small Calendula bloom as a badge in the bottom-right corner.
|
||||||
|
|
||||||
|
Strokes render in off-white (#FAF6F0) over the slate background
|
||||||
|
drawable (drawable/ic_launcher_background.xml = @color/ic_launcher_background).
|
||||||
|
The same vector is reused as the <monochrome> slot in the adaptive icon
|
||||||
|
so Android 13+ themed-icon launchers can recolor it from wallpaper.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="232"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="232">
|
||||||
<!--
|
<!--
|
||||||
Stylized "1" centered in the 108x108 viewport.
|
Android adaptive icon spec: 108dp canvas.
|
||||||
Reference: kalendae (the first day of the month) - etymological root
|
|
||||||
of both "Calendar" and "Calendula".
|
Centering Logic:
|
||||||
Color is off-white for high contrast on the slate background.
|
- The calendar body is a 142x142 square centered at (114, 108).
|
||||||
|
- The viewport center is (116, 116).
|
||||||
|
- We use pivot (114, 108) and translate by (2, 8) to align the
|
||||||
|
calendar's geometric center perfectly with the canvas center,
|
||||||
|
ignoring the visual weight of the bloom badge.
|
||||||
|
|
||||||
|
Scale:
|
||||||
|
- Scaled by 0.50 to provide significant padding, preventing a
|
||||||
|
"zoomed in" look on home screens and splash screens.
|
||||||
-->
|
-->
|
||||||
|
<group
|
||||||
|
android:pivotX="114"
|
||||||
|
android:pivotY="108"
|
||||||
|
android:scaleX="0.50"
|
||||||
|
android:scaleY="0.50"
|
||||||
|
android:translateX="2"
|
||||||
|
android:translateY="8">
|
||||||
|
<!-- Calendar body (rounded square with horizontal header divider) -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="12"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeMiterLimit="12"
|
||||||
|
android:pathData="M43,69H185M185,115V63C185,48.64 173.359,37 159,37H69C54.64,37 43,48.64 43,63V153C43,167.359 54.64,179 69,179H124" />
|
||||||
|
<!-- Numeral "1" inside the calendar body -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="12"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M103,110L113.999,99V142.428" />
|
||||||
|
<!-- Calendula bloom: 8 petals around a filled center -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M163.072,136.714C163.072,142.886 168.214,153.429 170.786,157.929C173.357,153.429 178.5,142.886 178.5,136.714C178.5,130.543 173.357,129 170.786,129C168.214,129 163.072,130.543 163.072,136.714Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M178.5,186.857C178.5,180.686 173.357,170.143 170.786,165.643C168.214,170.143 163.072,180.686 163.072,186.857C163.072,193.029 168.214,194.572 170.786,194.572C173.357,194.572 178.5,193.029 178.5,186.857Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M195.857,154.072C189.686,154.072 179.143,159.214 174.643,161.786C179.143,164.357 189.686,169.5 195.857,169.5C202.029,169.5 203.572,164.357 203.572,161.786C203.572,159.214 202.029,154.072 195.857,154.072Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M145.714,170.008C151.886,170.008 162.429,164.865 166.929,162.294C162.429,159.722 151.886,154.58 145.714,154.58C139.543,154.58 138,159.722 138,162.294C138,164.865 139.543,170.008 145.714,170.008Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M194.768,174.858C190.404,170.494 179.312,166.676 174.312,165.312C175.676,170.312 179.494,181.404 183.858,185.768C188.222,190.132 192.949,187.586 194.768,185.768C196.586,183.949 199.132,179.222 194.768,174.858Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M146.804,149.222C151.168,153.586 162.259,157.404 167.26,158.768C165.896,153.767 162.077,142.676 157.714,138.312C153.35,133.948 148.622,136.494 146.804,138.312C144.986,140.13 142.44,144.858 146.804,149.222Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M183.858,138.312C179.494,142.676 175.676,153.767 174.312,158.768C179.312,157.404 190.404,153.586 194.768,149.222C199.132,144.858 196.586,140.13 194.768,138.312C192.949,136.494 188.222,133.948 183.858,138.312Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="8"
|
||||||
|
android:pathData="M157.714,185.768C162.077,181.404 165.896,170.312 167.26,165.312C162.259,166.676 151.168,170.494 146.804,174.858C142.44,179.222 144.986,183.949 146.804,185.768C148.622,187.586 153.35,190.132 157.714,185.768Z" />
|
||||||
|
<!-- Calendula center disc -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFAF6F0"
|
android:fillColor="#FFFAF6F0"
|
||||||
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" />
|
android:pathData="M170.786,169C174.77,169 178,165.77 178,161.786C178,157.802 174.77,154.572 170.786,154.572C166.802,154.572 163.572,157.802 163.572,161.786C163.572,165.77 166.802,169 170.786,169Z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -10,4 +10,170 @@
|
|||||||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||||||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
|
|
||||||
|
<!-- Permission-Flow (F1) -->
|
||||||
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
|
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||||
|
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||||
|
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||||
|
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||||
|
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||||
|
<string name="permission_retry_button">Erneut versuchen</string>
|
||||||
|
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
|
||||||
|
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||||
|
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||||
|
|
||||||
|
<!-- Monatsansicht (S1) -->
|
||||||
|
<string name="month_prev">Vorheriger Monat</string>
|
||||||
|
<string name="month_next">Nächster Monat</string>
|
||||||
|
<string name="month_today_action">Heute</string>
|
||||||
|
<string name="month_more_actions">Weitere Aktionen</string>
|
||||||
|
<string name="month_open_menu">Menü öffnen</string>
|
||||||
|
<string name="month_action_settings">Einstellungen</string>
|
||||||
|
<string name="month_a11y_today_prefix">Heute</string>
|
||||||
|
|
||||||
|
<!-- Wochenansicht (S2) -->
|
||||||
|
<string name="week_today_action">Diese Woche</string>
|
||||||
|
<string name="week_number_label">KW</string>
|
||||||
|
|
||||||
|
<!-- Tagesansicht (S3) -->
|
||||||
|
<string name="day_today_action">Heute</string>
|
||||||
|
|
||||||
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
|
<string name="event_detail_back">Zurück</string>
|
||||||
|
<string name="event_detail_delete">Löschen</string>
|
||||||
|
<string name="event_delete_title">Termin löschen?</string>
|
||||||
|
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||||
|
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||||
|
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
|
||||||
|
<string name="event_delete_option_series">Alle Termine der Serie</string>
|
||||||
|
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||||
|
<string name="event_edit_new_title">Neuer Termin</string>
|
||||||
|
<string name="event_edit_close">Schließen</string>
|
||||||
|
<string name="event_edit_save">Speichern</string>
|
||||||
|
<string name="event_edit_title_hint">Titel hinzufügen</string>
|
||||||
|
<string name="event_edit_starts">Beginn</string>
|
||||||
|
<string name="event_edit_ends">Ende</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Endet vor dem Beginn</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Kein beschreibbarer Kalender verfügbar</string>
|
||||||
|
<string name="event_edit_save_failed">Termin konnte nicht gespeichert werden</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula braucht Schreibzugriff, um Termine zu erstellen</string>
|
||||||
|
<string name="event_edit_more_fields">Weitere Felder</string>
|
||||||
|
<string name="event_edit_add">Hinzufügen</string>
|
||||||
|
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||||
|
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||||
|
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||||
|
<string name="reminder_unit_minutes">Minuten</string>
|
||||||
|
<string name="reminder_unit_hours">Stunden</string>
|
||||||
|
<string name="reminder_unit_days">Tage</string>
|
||||||
|
<string name="reminder_unit_weeks">Wochen</string>
|
||||||
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
<string name="event_availability_busy">Beschäftigt</string>
|
||||||
|
<string name="event_access_default">Standard</string>
|
||||||
|
<string name="event_access_public">Öffentlich</string>
|
||||||
|
<string name="event_detail_all_day">Ganztägig</string>
|
||||||
|
<string name="event_detail_calendar">Kalender</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||||
|
<string name="event_detail_location">Ort</string>
|
||||||
|
<string name="event_detail_description">Beschreibung</string>
|
||||||
|
<string name="event_detail_attendees">Teilnehmer</string>
|
||||||
|
<string name="event_detail_recurrence">Wiederholung</string>
|
||||||
|
<string name="event_detail_recurring">Wiederkehrender Termin</string>
|
||||||
|
<string name="recurrence_daily">Jeden Tag</string>
|
||||||
|
<string name="recurrence_weekly">Jede Woche</string>
|
||||||
|
<string name="recurrence_monthly">Jeden Monat</string>
|
||||||
|
<string name="recurrence_yearly">Jedes Jahr</string>
|
||||||
|
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
|
||||||
|
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
|
||||||
|
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
|
||||||
|
<string name="recurrence_on_days">%1$s am %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s bis %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
|
||||||
|
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
|
||||||
|
<string name="event_attendee_accepted">Zugesagt</string>
|
||||||
|
<string name="event_attendee_declined">Abgesagt</string>
|
||||||
|
<string name="event_attendee_tentative">Vorläufig</string>
|
||||||
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Erinnerungen</string>
|
||||||
|
<string name="event_detail_timezone">Zeitzone</string>
|
||||||
|
<string name="event_status_tentative">Vorläufig</string>
|
||||||
|
<string name="event_status_cancelled">Abgesagt</string>
|
||||||
|
<string name="event_availability_free">Frei</string>
|
||||||
|
<string name="event_access_private">Privat</string>
|
||||||
|
<string name="event_access_confidential">Vertraulich</string>
|
||||||
|
<string name="event_attendee_organizer">Organisator</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Ressource</string>
|
||||||
|
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Zur Startzeit</string>
|
||||||
|
<string name="reminder_default">Standarderinnerung</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d Minute vorher</item>
|
||||||
|
<item quantity="other">%d Minuten vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d Stunde vorher</item>
|
||||||
|
<item quantity="other">%d Stunden vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d Tag vorher</item>
|
||||||
|
<item quantity="other">%d Tage vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d Woche vorher</item>
|
||||||
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- Geteilte Event-Strings -->
|
||||||
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
<!-- View-Switcher (M1) -->
|
||||||
|
<string name="view_month">Monat</string>
|
||||||
|
<string name="view_week">Woche</string>
|
||||||
|
<string name="view_day">Tag</string>
|
||||||
|
|
||||||
|
<!-- Kalender-Filter (M3) -->
|
||||||
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
|
<!-- Einstellungen (M4) -->
|
||||||
|
<string name="settings_title">Einstellungen</string>
|
||||||
|
<string name="settings_back">Zurück</string>
|
||||||
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
|
<string name="settings_theme">Design</string>
|
||||||
|
<string name="settings_theme_system">System</string>
|
||||||
|
<string name="settings_theme_light">Hell</string>
|
||||||
|
<string name="settings_theme_dark">Dunkel</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamische Farben</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||||
|
<string name="settings_week_start">Wochenstart</string>
|
||||||
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
|
<string name="settings_week_start_monday">Montag</string>
|
||||||
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_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>
|
||||||
|
<string name="settings_section_about">Über</string>
|
||||||
|
<string name="settings_version">Version</string>
|
||||||
|
<string name="settings_license">Lizenz</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_source">Quellcode</string>
|
||||||
|
<string name="settings_source_open">Öffnen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -11,4 +11,171 @@
|
|||||||
<string name="state_failure_no_calendars">No calendars configured.</string>
|
<string name="state_failure_no_calendars">No calendars configured.</string>
|
||||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
|
||||||
|
<!-- Permission flow (F1) -->
|
||||||
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||||
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
|
<string name="permission_denied_title">Calendar access denied</string>
|
||||||
|
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||||
|
<string name="permission_open_settings_button">Open system settings</string>
|
||||||
|
<string name="permission_retry_button">Try again</string>
|
||||||
|
<string name="permission_benefit_private_title">Stays on your device</string>
|
||||||
|
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
||||||
|
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||||
|
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||||
|
|
||||||
|
<!-- Month view (S1) -->
|
||||||
|
<string name="month_prev">Previous month</string>
|
||||||
|
<string name="month_next">Next month</string>
|
||||||
|
<string name="month_today_action">Today</string>
|
||||||
|
<string name="month_more_actions">More actions</string>
|
||||||
|
<string name="month_open_menu">Open menu</string>
|
||||||
|
<string name="month_action_settings">Settings</string>
|
||||||
|
<string name="month_a11y_today_prefix">Today</string>
|
||||||
|
|
||||||
|
<!-- Week view (S2) -->
|
||||||
|
<string name="week_today_action">This week</string>
|
||||||
|
<string name="week_number_label">Wk</string>
|
||||||
|
|
||||||
|
<!-- Day view (S3) -->
|
||||||
|
<string name="day_today_action">Today</string>
|
||||||
|
|
||||||
|
<!-- Event detail screen (S4) -->
|
||||||
|
<string name="event_detail_back">Back</string>
|
||||||
|
<string name="event_detail_delete">Delete</string>
|
||||||
|
<string name="event_delete_title">Delete event?</string>
|
||||||
|
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||||
|
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||||
|
<string name="event_delete_option_occurrence">Only this event</string>
|
||||||
|
<string name="event_delete_option_series">All events in the series</string>
|
||||||
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
|
<string name="dialog_cancel">Cancel</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Event form (v1.2 create) -->
|
||||||
|
<string name="event_edit_new_title">New event</string>
|
||||||
|
<string name="event_edit_close">Close</string>
|
||||||
|
<string name="event_edit_save">Save</string>
|
||||||
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
|
<string name="event_edit_starts">Starts</string>
|
||||||
|
<string name="event_edit_ends">Ends</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No writable calendar available</string>
|
||||||
|
<string name="event_edit_save_failed">Couldn\'t save the event</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula needs write access to create events</string>
|
||||||
|
<string name="event_edit_more_fields">More fields</string>
|
||||||
|
<string name="event_edit_add">Add</string>
|
||||||
|
<string name="event_edit_add_reminder">Add reminder</string>
|
||||||
|
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||||
|
<string name="event_edit_reminder_custom">Custom</string>
|
||||||
|
<string name="reminder_unit_minutes">minutes</string>
|
||||||
|
<string name="reminder_unit_hours">hours</string>
|
||||||
|
<string name="reminder_unit_days">days</string>
|
||||||
|
<string name="reminder_unit_weeks">weeks</string>
|
||||||
|
<string name="event_edit_availability">Availability</string>
|
||||||
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
<string name="event_availability_busy">Busy</string>
|
||||||
|
<string name="event_access_default">Default</string>
|
||||||
|
<string name="event_access_public">Public</string>
|
||||||
|
<string name="event_detail_all_day">All day</string>
|
||||||
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
|
<string name="event_detail_location">Location</string>
|
||||||
|
<string name="event_detail_description">Description</string>
|
||||||
|
<string name="event_detail_attendees">Attendees</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrence</string>
|
||||||
|
<string name="event_detail_recurring">Repeating event</string>
|
||||||
|
<string name="recurrence_daily">Every day</string>
|
||||||
|
<string name="recurrence_weekly">Every week</string>
|
||||||
|
<string name="recurrence_monthly">Every month</string>
|
||||||
|
<string name="recurrence_yearly">Every year</string>
|
||||||
|
<string name="recurrence_every_n_days">Every %1$d days</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
|
||||||
|
<string name="recurrence_every_n_months">Every %1$d months</string>
|
||||||
|
<string name="recurrence_every_n_years">Every %1$d years</string>
|
||||||
|
<string name="recurrence_on_days">%1$s on %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s until %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d times</string>
|
||||||
|
<string name="event_detail_not_found">This event no longer exists.</string>
|
||||||
|
<string name="event_attendee_accepted">Accepted</string>
|
||||||
|
<string name="event_attendee_declined">Declined</string>
|
||||||
|
<string name="event_attendee_tentative">Tentative</string>
|
||||||
|
<string name="event_attendee_needs_action">No response</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event detail — full read (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Reminders</string>
|
||||||
|
<string name="event_detail_timezone">Time zone</string>
|
||||||
|
<string name="event_status_tentative">Tentative</string>
|
||||||
|
<string name="event_status_cancelled">Cancelled</string>
|
||||||
|
<string name="event_availability_free">Free</string>
|
||||||
|
<string name="event_access_private">Private</string>
|
||||||
|
<string name="event_access_confidential">Confidential</string>
|
||||||
|
<string name="event_attendee_organizer">Organizer</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Resource</string>
|
||||||
|
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||||
|
<string name="reminder_at_time">At time of event</string>
|
||||||
|
<string name="reminder_default">Default reminder</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minute before</item>
|
||||||
|
<item quantity="other">%d minutes before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hour before</item>
|
||||||
|
<item quantity="other">%d hours before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d day before</item>
|
||||||
|
<item quantity="other">%d days before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d week before</item>
|
||||||
|
<item quantity="other">%d weeks before</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- Shared event strings -->
|
||||||
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
<!-- View switcher (M1) -->
|
||||||
|
<string name="view_month">Month</string>
|
||||||
|
<string name="view_week">Week</string>
|
||||||
|
<string name="view_day">Day</string>
|
||||||
|
|
||||||
|
<!-- Calendar filter (M3) -->
|
||||||
|
<string name="filter_title">Calendars</string>
|
||||||
|
|
||||||
|
<!-- Settings (M4) -->
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_back">Back</string>
|
||||||
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
|
<string name="settings_theme">Theme</string>
|
||||||
|
<string name="settings_theme_system">System</string>
|
||||||
|
<string name="settings_theme_light">Light</string>
|
||||||
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
|
<string name="settings_week_start">Week starts on</string>
|
||||||
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
|
<string name="settings_week_start_monday">Monday</string>
|
||||||
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
|
<string name="settings_section_event_form">New event form</string>
|
||||||
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_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>
|
||||||
|
<string name="settings_section_about">About</string>
|
||||||
|
<string name="settings_version">Version</string>
|
||||||
|
<string name="settings_license">License</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_source">Source code</string>
|
||||||
|
<string name="settings_source_open">Open</string>
|
||||||
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class CalendarMapperTest {
|
||||||
|
|
||||||
|
private fun reader(
|
||||||
|
id: Long = 1L,
|
||||||
|
displayName: String? = "Cal",
|
||||||
|
accountName: String? = "x@y",
|
||||||
|
accountType: String? = "LOCAL",
|
||||||
|
color: Int = 0,
|
||||||
|
visible: Int = 1,
|
||||||
|
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
): MapColumnReader = MapColumnReader(
|
||||||
|
CalendarProjection.IDX_ID to id,
|
||||||
|
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||||
|
CalendarProjection.IDX_ACCOUNT_NAME to accountName,
|
||||||
|
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
|
||||||
|
CalendarProjection.IDX_COLOR to color,
|
||||||
|
CalendarProjection.IDX_VISIBLE to visible,
|
||||||
|
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `happy path maps all six columns`() {
|
||||||
|
val src = reader(
|
||||||
|
id = 42L,
|
||||||
|
displayName = "Work",
|
||||||
|
accountName = "x@y",
|
||||||
|
accountType = "com.google",
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
visible = 1,
|
||||||
|
).toCalendarSource()
|
||||||
|
assertThat(src).isEqualTo(
|
||||||
|
de.jeanlucmakiola.calendula.domain.CalendarSource(
|
||||||
|
id = 42L,
|
||||||
|
displayName = "Work",
|
||||||
|
accountName = "x@y",
|
||||||
|
accountType = "com.google",
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
canModifyContents = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null displayName falls back to placeholder`() {
|
||||||
|
val src = reader(displayName = null).toCalendarSource()
|
||||||
|
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `visible flag 0 maps to false`() {
|
||||||
|
assertThat(reader(visible = 0).toCalendarSource().isVisibleInSystem).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `visible flag 1 maps to true`() {
|
||||||
|
assertThat(reader(visible = 1).toCalendarSource().isVisibleInSystem).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null accountName and accountType coerce to empty string`() {
|
||||||
|
val src = reader(accountName = null, accountType = null).toCalendarSource()
|
||||||
|
assertThat(src.accountName).isEqualTo("")
|
||||||
|
assertThat(src.accountType).isEqualTo("")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contributor access and above can modify contents`() {
|
||||||
|
val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
|
||||||
|
val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||||
|
assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
|
||||||
|
assertThat(owner.toCalendarSource().canModifyContents).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read access cannot modify contents`() {
|
||||||
|
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
|
||||||
|
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing access level defaults to read-only`() {
|
||||||
|
// WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
|
||||||
|
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
|
||||||
|
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
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.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CalendarRepositoryImplTest {
|
||||||
|
|
||||||
|
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||||
|
CalendarPrefs(newDataStore(tempDir))
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
|
||||||
|
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||||
|
|
||||||
|
private fun makeEvent(
|
||||||
|
id: Long,
|
||||||
|
title: String = "E $id",
|
||||||
|
calendarId: Long = 1L,
|
||||||
|
): EventInstance = EventInstance(
|
||||||
|
instanceId = id, eventId = id, calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||||
|
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||||
|
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calendars emits initial query result on subscribe`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
repo.calendars().test {
|
||||||
|
val first = awaitItem()
|
||||||
|
assertThat(first.map { it.id }).containsExactly(1L, 2L).inOrder()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calendars re-emits after change listener tick`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(makeCal(1L))
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
repo.calendars().test {
|
||||||
|
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||||
|
|
||||||
|
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||||
|
fake.tick()
|
||||||
|
|
||||||
|
assertThat(awaitItem().map { it.id }).containsExactly(1L, 2L).inOrder()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var observedBegin: Long? = null
|
||||||
|
var observedEnd: Long? = null
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = {b, e ->
|
||||||
|
observedBegin = b
|
||||||
|
observedEnd = e
|
||||||
|
listOf(makeEvent(10L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
awaitItem()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
assertThat(observedBegin).isEqualTo(1_000L)
|
||||||
|
assertThat(observedEnd).isEqualTo(2_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances passes-through whatever the data source returns`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
val first = awaitItem()
|
||||||
|
assertThat(first.map { it.title }).containsExactly("Good")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances drops events whose calendar the user hid`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
prefs.setHiddenCalendarIds(setOf(2L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "Visible", calendarId = 1L),
|
||||||
|
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("Visible")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emits when the hidden set changes`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "A", calendarId = 1L),
|
||||||
|
makeEvent(11L, "B", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||||
|
|
||||||
|
prefs.setHiddenCalendarIds(setOf(2L))
|
||||||
|
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Stand-up",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val id = repo.createEvent(form)
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(77L)
|
||||||
|
assertThat(fake.insertedForms).containsExactly(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.createEvent(form)
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("insert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||||
|
assertThat(fake.deletedOccurrences).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedOccurrences).containsExactly(42L to 1_000L)
|
||||||
|
assertThat(fake.deletedEventIds).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("delete event id=42")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { null }
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.eventDetail(eventId = 999L)
|
||||||
|
error("Expected NoSuchEventException")
|
||||||
|
} catch (expected: NoSuchEventException) {
|
||||||
|
assertThat(expected.message).contains("999")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventDetailMapperTest {
|
||||||
|
|
||||||
|
private fun detailReader(
|
||||||
|
eventId: Long = 1L,
|
||||||
|
title: String? = "Meet",
|
||||||
|
description: String? = "Body",
|
||||||
|
organizer: String? = "x@y",
|
||||||
|
rrule: String? = null,
|
||||||
|
eventColor: Any? = null,
|
||||||
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
|
dtstart: Long = 1_000_000_000L,
|
||||||
|
dtend: Long = 1_000_003_600L,
|
||||||
|
allDay: Int = 0,
|
||||||
|
location: String? = "Berlin",
|
||||||
|
calendarId: Long = 7L,
|
||||||
|
status: Any? = null,
|
||||||
|
availability: Any? = null,
|
||||||
|
accessLevel: Any? = null,
|
||||||
|
timezone: String? = null,
|
||||||
|
selfStatus: Any? = null,
|
||||||
|
): MapColumnReader = MapColumnReader(
|
||||||
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
|
EventDetailProjection.IDX_DESCRIPTION to description,
|
||||||
|
EventDetailProjection.IDX_ORGANIZER to organizer,
|
||||||
|
EventDetailProjection.IDX_RRULE to rrule,
|
||||||
|
EventDetailProjection.IDX_EVENT_COLOR to eventColor,
|
||||||
|
EventDetailProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||||
|
EventDetailProjection.IDX_DTSTART to dtstart,
|
||||||
|
EventDetailProjection.IDX_DTEND to dtend,
|
||||||
|
EventDetailProjection.IDX_ALL_DAY to allDay,
|
||||||
|
EventDetailProjection.IDX_LOCATION to location,
|
||||||
|
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
EventDetailProjection.IDX_STATUS to status,
|
||||||
|
EventDetailProjection.IDX_AVAILABILITY to availability,
|
||||||
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun attendeeReader(
|
||||||
|
name: String?,
|
||||||
|
email: String?,
|
||||||
|
status: Int,
|
||||||
|
relationship: Int = 0,
|
||||||
|
type: Int = 0,
|
||||||
|
): MapColumnReader =
|
||||||
|
MapColumnReader(
|
||||||
|
AttendeeProjection.IDX_NAME to name,
|
||||||
|
AttendeeProjection.IDX_EMAIL to email,
|
||||||
|
AttendeeProjection.IDX_STATUS to status,
|
||||||
|
AttendeeProjection.IDX_RELATIONSHIP to relationship,
|
||||||
|
AttendeeProjection.IDX_TYPE to type,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
|
||||||
|
MapColumnReader(
|
||||||
|
ReminderProjection.IDX_MINUTES to minutes,
|
||||||
|
ReminderProjection.IDX_METHOD to method,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MapColumnReader.toDetail(
|
||||||
|
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
||||||
|
reminders: List<Reminder> = emptyList(),
|
||||||
|
) = toEventDetailCore(attendees, reminders)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
||||||
|
val detail = detailReader().toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.description).isEqualTo("Body")
|
||||||
|
assertThat(detail.organizer).isEqualTo("x@y")
|
||||||
|
assertThat(detail.instance.title).isEqualTo("Meet")
|
||||||
|
assertThat(detail.instance.location).isEqualTo("Berlin")
|
||||||
|
assertThat(detail.attendees).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event color falls back to calendar color when null`() {
|
||||||
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
|
.toDetail()
|
||||||
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dtend before dtstart drops detail`() {
|
||||||
|
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||||
|
assertThat(detail).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rrule passes through when present`() {
|
||||||
|
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||||
|
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw CalendarContract.Attendees status integer constants from the Android
|
||||||
|
// source (kept inline so the test doesn't depend on the mockable.jar's
|
||||||
|
// possibly-stubbed constants):
|
||||||
|
// ACCEPTED=1, DECLINED=2, INVITED=3, TENTATIVE=4, NONE=0
|
||||||
|
@Test
|
||||||
|
fun `attendee status maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.Accepted)
|
||||||
|
assertThat(attendeeReader("B", "b@x", 2).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.Declined)
|
||||||
|
assertThat(attendeeReader("C", "c@x", 4).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.Tentative)
|
||||||
|
assertThat(attendeeReader("D", "d@x", 3).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.NeedsAction)
|
||||||
|
assertThat(attendeeReader("E", "e@x", 0).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.Unknown)
|
||||||
|
assertThat(attendeeReader("F", "f@x", 99).toAttendee().status)
|
||||||
|
.isEqualTo(AttendeeStatus.Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attendee with null name maps to empty string`() {
|
||||||
|
val a = attendeeReader(null, "alice@x", 1).toAttendee()
|
||||||
|
assertThat(a.name).isEqualTo("")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attendee email passes through nullably`() {
|
||||||
|
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
|
||||||
|
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
|
||||||
|
@Test
|
||||||
|
fun `attendee relationship maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Organizer)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Attendee)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
|
||||||
|
@Test
|
||||||
|
fun `attendee type maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Required)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Optional)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
|
||||||
|
@Test
|
||||||
|
fun `event status null maps to confirmed, codes map through`() {
|
||||||
|
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
|
||||||
|
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
|
||||||
|
@Test
|
||||||
|
fun `availability null or busy maps to Busy, free maps to Free`() {
|
||||||
|
assertThat(detailReader(availability = null).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 0).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 1).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Free)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
|
||||||
|
@Test
|
||||||
|
fun `access level maps known integer codes, null is Default`() {
|
||||||
|
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Default)
|
||||||
|
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Confidential)
|
||||||
|
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event timezone and self status pass through`() {
|
||||||
|
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
|
||||||
|
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders pass through to the detail`() {
|
||||||
|
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
|
||||||
|
val detail = detailReader().toDetail(reminders = reminders)
|
||||||
|
assertThat(detail!!.reminders).isEqualTo(reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
|
||||||
|
@Test
|
||||||
|
fun `reminder maps minutes and method codes`() {
|
||||||
|
assertThat(reminderReader(10, 1).toReminder())
|
||||||
|
.isEqualTo(Reminder(10, ReminderMethod.Alert))
|
||||||
|
assertThat(reminderReader(60, 2).toReminder())
|
||||||
|
.isEqualTo(Reminder(60, ReminderMethod.Email))
|
||||||
|
assertThat(reminderReader(0, 0).toReminder())
|
||||||
|
.isEqualTo(Reminder(0, ReminderMethod.Default))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class EventWriteMapperTest {
|
||||||
|
|
||||||
|
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun form(
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||||
|
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event resolves wall clock in the given zone`() {
|
||||||
|
val times = form().toWriteTimes(berlin)
|
||||||
|
// 2026-06-11 is CEST (UTC+2): 10:00 local == 08:00Z.
|
||||||
|
assertThat(times.dtStartMillis).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(90L * 60L * 1000L)
|
||||||
|
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||||
|
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||||
|
assertThat(times.timezone).isEqualTo("UTC")
|
||||||
|
assertThat(times.dtStartMillis % 86_400_000L).isEqualTo(0L)
|
||||||
|
// Single-day all-day event: DTEND is the NEXT UTC midnight.
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(86_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `availability maps to the provider constants`() {
|
||||||
|
assertThat(Availability.Busy.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.AVAILABILITY_BUSY)
|
||||||
|
assertThat(Availability.Free.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
||||||
|
assertThat(Availability.Tentative.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.AVAILABILITY_TENTATIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `access level maps to the provider constants`() {
|
||||||
|
assertThat(AccessLevel.Default.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.ACCESS_DEFAULT)
|
||||||
|
assertThat(AccessLevel.Private.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
||||||
|
assertThat(AccessLevel.Confidential.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.ACCESS_CONFIDENTIAL)
|
||||||
|
assertThat(AccessLevel.Public.toProviderValue())
|
||||||
|
.isEqualTo(CalendarContract.Events.ACCESS_PUBLIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day all-day event spans every covered day`() {
|
||||||
|
val times = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(0, 0)),
|
||||||
|
).toWriteTimes(berlin)
|
||||||
|
// 11th, 12th, 13th inclusive = 3 days.
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||||
|
* a provider change so the repository re-queries.
|
||||||
|
*/
|
||||||
|
internal class FakeCalendarDataSource : CalendarDataSource {
|
||||||
|
|
||||||
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
|
/** Set to make the next write call throw. */
|
||||||
|
var writeError: Exception? = null
|
||||||
|
/** Id returned by the next [insertEvent]. */
|
||||||
|
var nextInsertId: Long = 100L
|
||||||
|
|
||||||
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
|
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
|
||||||
|
private val listeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
override fun calendars(): List<CalendarSource> = calendarsResult
|
||||||
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
|
instancesResult(beginMillis, endMillis)
|
||||||
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
insertedForms += form
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEvent(eventId: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedEventIds += eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedOccurrences += eventId to beginMillis
|
||||||
|
}
|
||||||
|
override fun registerChangeListener(listener: () -> Unit) {
|
||||||
|
listeners += listener
|
||||||
|
}
|
||||||
|
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||||
|
listeners -= listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tick() {
|
||||||
|
listeners.forEach { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class InstanceMapperTest {
|
||||||
|
|
||||||
|
private fun reader(
|
||||||
|
instanceId: Long = 10L,
|
||||||
|
eventId: Long = 1L,
|
||||||
|
calendarId: Long = 1L,
|
||||||
|
title: String? = "Meet",
|
||||||
|
begin: Long = 1_000_000_000L,
|
||||||
|
end: Long = 1_000_003_600L,
|
||||||
|
allDay: Int = 0,
|
||||||
|
eventColor: Any? = null,
|
||||||
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
|
location: String? = null,
|
||||||
|
): MapColumnReader = MapColumnReader(
|
||||||
|
InstanceProjection.IDX_INSTANCE_ID to instanceId,
|
||||||
|
InstanceProjection.IDX_EVENT_ID to eventId,
|
||||||
|
InstanceProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
InstanceProjection.IDX_TITLE to title,
|
||||||
|
InstanceProjection.IDX_BEGIN to begin,
|
||||||
|
InstanceProjection.IDX_END to end,
|
||||||
|
InstanceProjection.IDX_ALL_DAY to allDay,
|
||||||
|
InstanceProjection.IDX_EVENT_COLOR to eventColor,
|
||||||
|
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||||
|
InstanceProjection.IDX_LOCATION to location,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `happy path - non-allday event`() {
|
||||||
|
val inst = reader().toEventInstance()
|
||||||
|
assertThat(inst).isNotNull()
|
||||||
|
assertThat(inst!!.title).isEqualTo("Meet")
|
||||||
|
assertThat(inst.isAllDay).isFalse()
|
||||||
|
assertThat(inst.start).isEqualTo(Instant.fromEpochMilliseconds(1_000_000_000L))
|
||||||
|
assertThat(inst.end).isEqualTo(Instant.fromEpochMilliseconds(1_000_003_600L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event color falls back to calendar color when null`() {
|
||||||
|
val inst = reader(eventColor = null, calendarColor = 0xFF112233.toInt()).toEventInstance()
|
||||||
|
assertThat(inst!!.color).isEqualTo(0xFF112233.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event color wins over calendar color when present`() {
|
||||||
|
val inst = reader(
|
||||||
|
eventColor = 0xFFDEADBE.toInt(),
|
||||||
|
calendarColor = 0xFF112233.toInt(),
|
||||||
|
).toEventInstance()
|
||||||
|
assertThat(inst!!.color).isEqualTo(0xFFDEADBE.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null title falls back to placeholder`() {
|
||||||
|
val inst = reader(title = null).toEventInstance()
|
||||||
|
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty title falls back to placeholder`() {
|
||||||
|
val inst = reader(title = "").toEventInstance()
|
||||||
|
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dtend before dtstart drops the row`() {
|
||||||
|
val inst = reader(begin = 2000L, end = 1000L).toEventInstance()
|
||||||
|
assertThat(inst).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dtstart before unix epoch drops the row`() {
|
||||||
|
val inst = reader(begin = -1L, end = 1000L).toEventInstance()
|
||||||
|
assertThat(inst).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day flag 1 maps to true`() {
|
||||||
|
val inst = reader(allDay = 1).toEventInstance()
|
||||||
|
assertThat(inst!!.isAllDay).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `location passes through when present`() {
|
||||||
|
val inst = reader(location = "Berlin").toEventInstance()
|
||||||
|
assertThat(inst!!.location).isEqualTo("Berlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only ColumnReader. Backed by a Map<Int, Any?>; any missing index is
|
||||||
|
* treated as null. Numeric getters coerce via toLong/toInt; non-numeric values
|
||||||
|
* yield zero (matching Android Cursor behavior for type-mismatched reads).
|
||||||
|
*/
|
||||||
|
internal class MapColumnReader(values: Map<Int, Any?>) : ColumnReader {
|
||||||
|
|
||||||
|
private val data: Map<Int, Any?> = values
|
||||||
|
|
||||||
|
constructor(vararg pairs: Pair<Int, Any?>) : this(pairs.toMap())
|
||||||
|
|
||||||
|
override fun getLong(index: Int): Long = when (val v = data[index]) {
|
||||||
|
is Number -> v.toLong()
|
||||||
|
is String -> v.toLongOrNull() ?: 0L
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getString(index: Int): String? = data[index]?.toString()
|
||||||
|
|
||||||
|
override fun getInt(index: Int): Int = when (val v = data[index]) {
|
||||||
|
is Number -> v.toInt()
|
||||||
|
is String -> v.toIntOrNull() ?: 0
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isNull(index: Int): Boolean = data[index] == null
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class CalendarPrefsTest {
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hiddenCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setHiddenCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = CalendarPrefs(store)
|
||||||
|
prefs.setHiddenCalendarIds(setOf(1L, 42L, 7L))
|
||||||
|
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting empty set clears storage`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setHiddenCalendarIds(setOf(1L))
|
||||||
|
prefs.setHiddenCalendarIds(emptySet())
|
||||||
|
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = CalendarPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val mutable = p.toMutablePreferences()
|
||||||
|
mutable[CalendarPrefs.HIDDEN_IDS_KEY] = "1,abc,3"
|
||||||
|
mutable
|
||||||
|
}
|
||||||
|
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class SettingsPrefsTest {
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `defaults are system theme, dynamic colour on, auto week start`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
|
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `theme mode round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setThemeMode(ThemeMode.DARK)
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setDynamicColor(false)
|
||||||
|
assertThat(prefs.dynamicColor.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.THEME_MODE_KEY] = "PLAID"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.defaultFormFields.first()).containsExactly(
|
||||||
|
EventFormField.Location,
|
||||||
|
EventFormField.Description,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
|
||||||
|
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
|
||||||
|
assertThat(prefs.defaultFormFields.first()).containsExactly(
|
||||||
|
EventFormField.Description,
|
||||||
|
EventFormField.Reminders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
|
||||||
|
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auto week start follows the locale convention`() {
|
||||||
|
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventFormTest {
|
||||||
|
|
||||||
|
private fun form(
|
||||||
|
calendarId: Long? = 1L,
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
|
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid timed form has no problems`() {
|
||||||
|
assertThat(form().problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing calendar is a problem`() {
|
||||||
|
assertThat(form(calendarId = null).problems())
|
||||||
|
.containsExactly(EventFormProblem.NoCalendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed end before start is a problem`() {
|
||||||
|
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
|
||||||
|
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero-length timed event is allowed`() {
|
||||||
|
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
|
assertThat(instant.problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day single day is allowed even though times match`() {
|
||||||
|
val allDay = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
assertThat(allDay.problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day end date before start date is a problem`() {
|
||||||
|
val bad = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `problems accumulate`() {
|
||||||
|
val bad = form(
|
||||||
|
calendarId = null,
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||||
|
)
|
||||||
|
assertThat(bad.problems()).containsExactly(
|
||||||
|
EventFormProblem.NoCalendar,
|
||||||
|
EventFormProblem.EndBeforeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class FilterGroupingTest {
|
||||||
|
|
||||||
|
private fun cal(
|
||||||
|
id: Long,
|
||||||
|
name: String,
|
||||||
|
account: String,
|
||||||
|
type: String = "com.example",
|
||||||
|
) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
accountName = account,
|
||||||
|
accountType = type,
|
||||||
|
color = 0xFF336699.toInt(),
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `groups calendars under their account, preserving order`() {
|
||||||
|
val calendars = listOf(
|
||||||
|
cal(1, "Personal", "alice@dav"),
|
||||||
|
cal(2, "Work", "alice@dav"),
|
||||||
|
cal(3, "Shared", "team@dav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = groupByAccount(calendars, hidden = emptySet())
|
||||||
|
|
||||||
|
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||||
|
assertThat(groups[0].calendars.map { it.displayName })
|
||||||
|
.containsExactly("Personal", "Work").inOrder()
|
||||||
|
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hidden ids mark calendars not visible`() {
|
||||||
|
val calendars = listOf(
|
||||||
|
cal(1, "Personal", "alice@dav"),
|
||||||
|
cal(2, "Work", "alice@dav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = groupByAccount(calendars, hidden = setOf(2L))
|
||||||
|
val rows = groups.single().calendars.associateBy { it.id }
|
||||||
|
|
||||||
|
assertThat(rows.getValue(1L).visible).isTrue()
|
||||||
|
assertThat(rows.getValue(2L).visible).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank account name falls back to type`() {
|
||||||
|
val groups = groupByAccount(
|
||||||
|
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
||||||
|
hidden = emptySet(),
|
||||||
|
)
|
||||||
|
assertThat(groups.single().account).isEqualTo("LOCAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WeekLayoutTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
|
||||||
|
// 2026-06-10 is a Wednesday; its Monday-anchored week starts 2026-06-08.
|
||||||
|
private val wed = LocalDate(2026, 6, 10)
|
||||||
|
private val mon = LocalDate(2026, 6, 8)
|
||||||
|
private val weekDays = (0..6).map { mon.plusDays(it) }
|
||||||
|
|
||||||
|
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||||
|
date.atTime(h, m).toInstant(zone)
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
allDay: Boolean = false,
|
||||||
|
id: Long = 1L,
|
||||||
|
title: String = "E",
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
isAllDay = allDay,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `startOfWeek snaps to monday`() {
|
||||||
|
assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
|
||||||
|
assertThat(mon.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekRange spans seven days`() {
|
||||||
|
val range = weekRange(mon, zone)
|
||||||
|
assertThat(range.start).isEqualTo(at(mon, 0, 0))
|
||||||
|
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
|
||||||
|
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `coversDay is true for any overlap and false otherwise`() {
|
||||||
|
val ev = event(at(wed, 9), at(wed, 10))
|
||||||
|
assertThat(ev.coversDay(wed, zone)).isTrue()
|
||||||
|
assertThat(ev.coversDay(mon, zone)).isFalse()
|
||||||
|
|
||||||
|
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
|
||||||
|
assertThat(multiDay.coversDay(mon, zone)).isTrue()
|
||||||
|
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
|
||||||
|
assertThat(multiDay.coversDay(wed, zone)).isTrue()
|
||||||
|
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single timed event gets one lane`() {
|
||||||
|
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
|
||||||
|
assertThat(blocks).hasSize(1)
|
||||||
|
val b = blocks.single()
|
||||||
|
assertThat(b.startMin).isEqualTo(9 * 60)
|
||||||
|
assertThat(b.endMin).isEqualTo(10 * 60 + 30)
|
||||||
|
assertThat(b.lane).isEqualTo(0)
|
||||||
|
assertThat(b.laneCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping events resolve to side-by-side lanes`() {
|
||||||
|
val a = event(at(wed, 9), at(wed, 11), id = 1L)
|
||||||
|
val b = event(at(wed, 10), at(wed, 12), id = 2L)
|
||||||
|
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
|
||||||
|
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
|
||||||
|
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `back-to-back events reuse one lane`() {
|
||||||
|
val a = event(at(wed, 9), at(wed, 10), id = 1L)
|
||||||
|
val b = event(at(wed, 10), at(wed, 11), id = 2L)
|
||||||
|
val blocks = layoutDay(listOf(a, b), wed, zone)
|
||||||
|
assertThat(blocks).hasSize(2)
|
||||||
|
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event spanning midnight is clipped to the day`() {
|
||||||
|
// Starts the previous evening, ends 02:00 on wed.
|
||||||
|
val ev = event(at(mon.plusDays(1), 22), at(wed, 2))
|
||||||
|
val blocks = layoutDay(listOf(ev), wed, zone)
|
||||||
|
assertThat(blocks).hasSize(1)
|
||||||
|
assertThat(blocks.single().startMin).isEqualTo(0)
|
||||||
|
assertThat(blocks.single().endMin).isEqualTo(2 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instant event is kept with zero-length`() {
|
||||||
|
val ev = event(at(wed, 12), at(wed, 12))
|
||||||
|
val blocks = layoutDay(listOf(ev), wed, zone)
|
||||||
|
assertThat(blocks).hasSize(1)
|
||||||
|
assertThat(blocks.single().startMin).isEqualTo(12 * 60)
|
||||||
|
assertThat(blocks.single().endMin).isEqualTo(12 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day events are excluded from the timed layout`() {
|
||||||
|
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
|
||||||
|
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `events on other days are dropped`() {
|
||||||
|
val ev = event(at(mon, 9), at(mon, 10))
|
||||||
|
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single-day all-day event is a one-column span`() {
|
||||||
|
// Wed only: start Wed 00:00, end Thu 00:00.
|
||||||
|
val ev = event(at(weekDays[2], 0), at(weekDays[3], 0), allDay = true)
|
||||||
|
val spans = layoutAllDay(listOf(ev), weekDays, zone)
|
||||||
|
assertThat(spans).hasSize(1)
|
||||||
|
val s = spans.single()
|
||||||
|
assertThat(s.startCol).isEqualTo(2)
|
||||||
|
assertThat(s.endCol).isEqualTo(2)
|
||||||
|
assertThat(s.lane).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day all-day event becomes one span across columns`() {
|
||||||
|
// Tue..Thu: end Fri 00:00 is exclusive, so Fri is not covered.
|
||||||
|
val ev = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true)
|
||||||
|
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
|
||||||
|
assertThat(s.startCol).isEqualTo(1)
|
||||||
|
assertThat(s.endCol).isEqualTo(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `span reaching outside the week is clamped to visible columns`() {
|
||||||
|
// Starts two days before Monday, ends Wed 00:00 → covers Mon..Tue.
|
||||||
|
val ev = event(at(mon.plusDays(-2), 0), at(weekDays[2], 0), allDay = true)
|
||||||
|
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
|
||||||
|
assertThat(s.startCol).isEqualTo(0)
|
||||||
|
assertThat(s.endCol).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping all-day spans get separate lanes`() {
|
||||||
|
val a = event(at(weekDays[0], 0), at(weekDays[3], 0), allDay = true, id = 1L) // Mon..Wed
|
||||||
|
val b = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Tue..Thu
|
||||||
|
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
|
||||||
|
assertThat(spans.map { it.lane }.toSet()).isEqualTo(setOf(0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disjoint all-day spans reuse one lane`() {
|
||||||
|
val a = event(at(weekDays[0], 0), at(weekDays[1], 0), allDay = true, id = 1L) // Mon
|
||||||
|
val b = event(at(weekDays[3], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Thu
|
||||||
|
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
|
||||||
|
assertThat(spans.all { it.lane == 0 }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
34
design/icon/calendula_launcher.svg
Normal file
34
design/icon/calendula_launcher.svg
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<svg width="232" height="232" viewBox="0 0 232 232" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!--
|
||||||
|
Composed Calendula launcher icon (full-bleed), generated to exactly match the
|
||||||
|
Android adaptive icon defined by:
|
||||||
|
- drawable/ic_launcher_background.xml (solid @color/ic_launcher_background = #5C6B7A)
|
||||||
|
- drawable/ic_launcher_foreground.xml (off-white #FAF6F0 mark from calendula_mark.svg)
|
||||||
|
|
||||||
|
The adaptive foreground group transform (scaleX/Y=0.50, pivot 114,108,
|
||||||
|
translate 2,8) is reproduced here as the SVG transform "translate(59,62) scale(0.5)"
|
||||||
|
because Android applies it as: p' = scale*p + pivot*(1-scale) + translate
|
||||||
|
x' = 0.5*x + 114*0.5 + 2 = 0.5*x + 59
|
||||||
|
y' = 0.5*y + 108*0.5 + 8 = 0.5*y + 62
|
||||||
|
|
||||||
|
This is the single source of truth for the F-Droid / store icon.png renders.
|
||||||
|
-->
|
||||||
|
<rect x="0" y="0" width="232" height="232" fill="#5C6B7A"/>
|
||||||
|
<g transform="translate(59,62) scale(0.5)" fill="none" stroke="#FAF6F0">
|
||||||
|
<!-- Calendar body (rounded square with horizontal header divider) -->
|
||||||
|
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Numeral "1" inside the calendar body -->
|
||||||
|
<path d="M103 110L113.999 99V142.428" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Calendula bloom: 8 petals around a filled center -->
|
||||||
|
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke-width="8"/>
|
||||||
|
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke-width="8"/>
|
||||||
|
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke-width="8"/>
|
||||||
|
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke-width="8"/>
|
||||||
|
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke-width="8"/>
|
||||||
|
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke-width="8"/>
|
||||||
|
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke-width="8"/>
|
||||||
|
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke-width="8"/>
|
||||||
|
<!-- Calendula center disc (filled, matches foreground <fillColor> slot) -->
|
||||||
|
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="#FAF6F0" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
13
design/icon/calendula_mark.svg
Normal file
13
design/icon/calendula_mark.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke="black" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M103 110L113.999 99V142.428" stroke="black" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke="black" stroke-width="8"/>
|
||||||
|
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="black" stroke="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
109
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
109
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Calendula kann Events anlegen, bearbeiten und löschen — direkt über
|
||||||
|
`CalendarContract`-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
|
||||||
|
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
|
||||||
|
ist für sich releasebar und lässt `./gradlew lint test assembleDebug` grün.
|
||||||
|
|
||||||
|
**Architecture:** Writes laufen durch dieselbe Schichtung wie Reads:
|
||||||
|
`ui/` → `CalendarRepository` (Interface) → `CalendarDataSource` →
|
||||||
|
`ContentResolver.insert/update/delete`. Kein neuer Layer, keine Transaktions-
|
||||||
|
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
|
||||||
|
`ContentObserver`-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
|
||||||
|
Domain bleibt pure Kotlin.
|
||||||
|
|
||||||
|
**Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):**
|
||||||
|
|
||||||
|
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt ins Manifest. Das Onboarding
|
||||||
|
fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt
|
||||||
|
nur READ — wer Write ablehnt, nutzt die App weiter read-only.
|
||||||
|
v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim
|
||||||
|
ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"-
|
||||||
|
Behauptung (wäre mit Manifest-Eintrag gelogen).
|
||||||
|
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
|
||||||
|
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
|
||||||
|
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
|
||||||
|
Geburtstags- und andere read-only-Kalender.
|
||||||
|
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
|
||||||
|
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_CANCELED` +
|
||||||
|
`ORIGINAL_INSTANCE_TIME`) vs. "Ganze Serie" (Delete der Events-Row).
|
||||||
|
Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen
|
||||||
|
Werten) folgt erst, wenn das Serien-Edit stabil ist.
|
||||||
|
4. **Kein RRULE-Editor in v1.2:** Create startet ohne Wiederholungs-UI
|
||||||
|
(einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/
|
||||||
|
monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
|
||||||
|
5. **Conflict UX (Spec V2 "event modified externally during edit"):** kein
|
||||||
|
Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen
|
||||||
|
(Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog
|
||||||
|
"Überschreiben / Verwerfen". Mehr ist YAGNI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| Slice | Inhalt | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||||
|
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||||
|
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen |
|
||||||
|
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
||||||
|
|
||||||
|
## v1.1 — Write-Fundament + Delete
|
||||||
|
|
||||||
|
**Build/Manifest:**
|
||||||
|
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
|
||||||
|
|
||||||
|
**Data layer:**
|
||||||
|
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
|
||||||
|
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
|
||||||
|
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
|
||||||
|
Full-Screen-Failure
|
||||||
|
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
|
||||||
|
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
|
||||||
|
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
|
||||||
|
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
|
||||||
|
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
|
||||||
|
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
|
||||||
|
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
|
||||||
|
WRITE-Request statt Failure-Screen
|
||||||
|
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
|
||||||
|
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
|
||||||
|
Erfolg → zurück, Fehler → Snackbar
|
||||||
|
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
|
||||||
|
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
|
||||||
|
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
|
||||||
|
- [x] `CalendarMapperTest`: Access-Level-Mapping
|
||||||
|
|
||||||
|
## v1.2 — Create
|
||||||
|
|
||||||
|
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
|
||||||
|
NoCalendar; leerer Titel und Instant-Events erlaubt)
|
||||||
|
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
|
||||||
|
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
|
||||||
|
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
|
||||||
|
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
|
||||||
|
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
|
||||||
|
beschreibbarer; Picker bietet nur beschreibbare Kalender an
|
||||||
|
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
|
||||||
|
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
|
||||||
|
|
||||||
|
## v1.3 — Edit (Skizze)
|
||||||
|
|
||||||
|
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
|
||||||
|
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
|
||||||
|
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
|
||||||
|
|
||||||
|
## v2.0 — Abschluss (Skizze)
|
||||||
|
|
||||||
|
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
|
||||||
|
- Occurrence-Edit (Exception mit geänderten Werten)
|
||||||
|
- Konflikt-Dialog beim Speichern
|
||||||
|
- Changelog, F-Droid-Metadaten, Release-Tag
|
||||||
@@ -27,7 +27,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- 3 Hauptansichten: Monat, Woche, Tag
|
- 3 Hauptansichten: Monat, Woche, Tag
|
||||||
- Event-Detail-Sheet (read-only Detailansicht)
|
- Event-Detail-Sheet (read-only Detailansicht)
|
||||||
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
||||||
- Heute-Button + Jump-to-Date
|
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
|
||||||
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
||||||
- Permission-Flow für `READ_CALENDAR`
|
- Permission-Flow für `READ_CALENDAR`
|
||||||
- Empty-States und Error-Recovery
|
- Empty-States und Error-Recovery
|
||||||
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- Tests + CI ab Tag 1
|
- Tests + CI ab Tag 1
|
||||||
|
|
||||||
### Out-of-Scope (V2+)
|
### Out-of-Scope (V2+)
|
||||||
|
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
|
||||||
- Event-Create/Edit/Delete (V2)
|
- Event-Create/Edit/Delete (V2)
|
||||||
- Home-Screen-Widget
|
- Home-Screen-Widget
|
||||||
- Volltextsuche
|
- Volltextsuche
|
||||||
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
|
|||||||
- Immer erreichbar von allen Hauptansichten
|
- Immer erreichbar von allen Hauptansichten
|
||||||
- State persistent (zuletzt aktive Ansicht)
|
- State persistent (zuletzt aktive Ansicht)
|
||||||
|
|
||||||
**M2 - Heute / Springe-zu-Datum**
|
**M2 - Heute**
|
||||||
- Schnell zurück zu "heute"
|
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
|
||||||
- Springe zu beliebigem Datum via Datum-Picker
|
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
|
||||||
- Erreichbar von allen Hauptansichten
|
- Erreichbar von allen Hauptansichten
|
||||||
|
|
||||||
**M3 - Kalender-Filter (Bottom-Sheet)**
|
**M3 - Kalender-Filter (Bottom-Sheet)**
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 16 KiB |
13
gradle/gradle-daemon-jvm.properties
Normal file
13
gradle/gradle-daemon-jvm.properties
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
|
||||||
|
toolchainVendor=JETBRAINS
|
||||||
|
toolchainVersion=21
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.1"
|
agp = "9.2.1"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
ksp = "2.3.9"
|
ksp = "2.3.9"
|
||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
lifecycleRuntime = "2.10.0"
|
lifecycleRuntime = "2.10.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
@@ -27,6 +28,7 @@ androidxTestRules = "1.7.0"
|
|||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX core
|
# AndroidX core
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
|
|||||||
|
|
||||||
# Material 3 (Expressive lives in this artifact for 1.5+)
|
# Material 3 (Expressive lives in this artifact for 1.5+)
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||||
|
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
# Hilt
|
# Hilt
|
||||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
|||||||
Reference in New Issue
Block a user