8 Commits

Author SHA1 Message Date
bdedf47972 release: cut v1.2.1 — event-form polish
All checks were successful
Build and Release to F-Droid / ci (push) Successful in 2m5s
CI / ci (push) Successful in 7m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m36s
Version bumped to 1.2.1 / 10. No code changes beyond the version — 1.2.1 is
the reviewed-and-approved form polish: card design system, optional fields
with settings defaults, reworked reminders, OptionCard dialogs app-wide,
expressive theme on standard springs, direction-aware today jump, IME fix.
CHANGELOG [1.2.1] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:41:11 +02:00
a69be3da43 feat(edit): form redesign, optional fields, OptionCard dialogs, expressive motion
All checks were successful
CI / ci (push) Successful in 5m56s
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:

- Form rebuilt on the detail screen's card system: tonal EditCards with
  gutter icons (centred on the first row, top-aligned for multiline),
  borderless inline fields (placeholders at half opacity), calendar-coloured
  title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
  visibility) with per-user defaults in Settings ("New event form" toggles);
  hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
  (one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
  written as METHOD_ALERT Reminders rows. Availability busy/free segmented
  toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
  calendar picker, visibility, more-fields, reminder presets and the
  recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
  felt overdone); FAB stack + field reveals animate on theme springs;
  jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
  mappings; DE+EN strings throughout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:30 +02:00
779fa1d480 release: cut v1.2.0 — event creation
All checks were successful
CI / ci (push) Successful in 7m47s
Build and Release to F-Droid / ci (push) Successful in 2m5s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m34s
Version bumped to 1.2.0 / 9. No code changes beyond the version — 1.2.0 is
the create slice: event form, "+" FAB on every view, last-used-calendar
preselect, provider-correct all-day storage. CHANGELOG [1.2.0] carries the
details; ROADMAP/STATE mark slice v1.2 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:17 +02:00
c59a071b82 feat(write): event creation — form screen, FAB, last-used calendar (v1.2)
Second slice of milestone 2 (write support):

- EventForm domain model + problems() validation (end-before-start,
  no-calendar; blank titles and instant events stay legal)
- Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers
  (moving the start preserves the duration), calendar picker limited to
  writable calendars, location, description. Save validates, requests the
  WRITE upgrade contextually, and closes on success
- Calendar preselection: explicit pick > last-used (CalendarPrefs) > first
  writable calendar
- insertEvent in the data source; EventWriteMapper (JVM-tested) normalises
  all-day events to UTC midnights with exclusive DTEND, timed events to the
  device zone
- CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on
  the visible day, jump-to-today pill stacked above it
- Tests: EventForm validation, write-time mapping (incl. DST-safe epoch
  check), repository createEvent delegation/error propagation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:08 +02:00
285bfd90a7 release: cut v1.1.0 — event delete (write foundation)
All checks were successful
CI / ci (push) Successful in 7m28s
Build and Release to F-Droid / ci (push) Successful in 2m1s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m17s
Version bumped to 1.1.0 / 8. No code changes beyond the version — 1.1.0 is
the write-foundation slice: WRITE_CALENDAR, read-only-calendar detection,
and event delete (whole series or single occurrence). CHANGELOG [1.1.0]
carries the details; ROADMAP/STATE mark slice v1.1 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:56 +02:00
9529f19c60 feat(write): event delete + WRITE_CALENDAR foundation (v1.1)
First slice of milestone 2 (write support), per the new plan in
docs/superpowers/plans/2026-06-11-03-write-support.md:

- Delete from the event detail screen with confirmation; recurring events
  choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI,
  series survives) or "all events in the series" (Events-row delete)
- WRITE_CALENDAR in the manifest; onboarding requests read+write in one
  system dialog but only read gates the app — declining write keeps it
  usable read-only. v1.0 installs get a contextual write request on their
  first delete
- CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents;
  read-only calendars (WebCal, birthdays, …) show no write actions. The
  no-op placeholder Edit button is removed until edit ships (v1.3)
- Onboarding copy drops the now-false "read-only" claim (DE+EN)
- Tests: repository delete delegation/error propagation, access-level
  mapping; FakeCalendarDataSource grows write ops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:15 +02:00
0013c9f3b1 ci: cut redundant per-run work (cache fix companion, emulator skip, daemon reuse)
All checks were successful
CI / ci (push) Successful in 14m34s
- skip setup-android's default packages (pulled the ~300 MB emulator every run)
- drop unused platforms;android-36 and the dead jq install step
- cache /opt/android-sdk and ~/.gradle (release.yaml had no cache at all)
- drop --no-daemon so lint/test/assemble reuse one warm daemon per job
- Trivy scan only on main (advisory-only; was ~25s tax on every branch push)
- concurrency group cancels superseded runs; drop duplicate pull_request trigger

Companion to the act_runner fix on the CI host: job containers now join the
runner's network so the actions/cache server is reachable (saves previously
failed with reserveCache timeouts, so no cache was ever stored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:50:01 +02:00
bd6ad4ae5f Merge pull request 'feat: full event read (v0.6.0) + onboarding redesign, cut v1.0.0' (#2) from feat/full-event-read-v0.6.0 into main
Some checks failed
CI / ci (push) Has been cancelled
2026-06-11 07:28:16 +00:00
43 changed files with 2845 additions and 122 deletions

View File

@@ -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=""

View File

@@ -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: |

View File

@@ -53,11 +53,19 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
- ~~Redesign the initial grant-access (permission) screen~~ — **done** - ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0) (Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support ## v2.0 — Write Support (in progress)
- Event create / edit / delete via `CalendarContract` writes Delivered in four releasable slices (plan:
- Quick-add sheet `docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
- Conflict UX (event modified externally during edit) 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

View File

@@ -4,10 +4,12 @@
## Status ## Status
**Milestone:** v1.0.0 — First public release (shipped 2026-06-11) **Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** V1 is complete and released. All screens done, the read model **Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
surfaces every readable `CalendarContract` field, and the onboarding screen Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support) 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
@@ -28,7 +30,22 @@ got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
URL field was cut — no `CalendarContract` column backs it.) 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. v1.0.0 released — monitor the F-Droid build/publish 1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX) recurrence picker
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0

View File

@@ -7,6 +7,83 @@ 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 ## [1.0.0] — 2026-06-11
First public release. Calendula is a read-only, Material 3 Expressive calendar First public release. Calendula is a read-only, Material 3 Expressive calendar

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 7 versionCode = 10
versionName = "1.0.0" versionName = "1.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -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,7 +18,8 @@
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" />

View File

@@ -2,18 +2,22 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -29,6 +33,19 @@ interface CalendarDataSource {
fun calendars(): List<CalendarSource> fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail? 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 registerChangeListener(listener: () -> Unit)
fun unregisterChangeListener(listener: () -> Unit) fun unregisterChangeListener(listener: () -> Unit)
} }
@@ -74,6 +91,65 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
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) { override fun registerChangeListener(listener: () -> Unit) {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) { val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) { override fun onChange(selfChange: Boolean) {
@@ -119,4 +195,8 @@ class AndroidCalendarDataSource @Inject constructor(
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList { private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
} }
private companion object {
const val TAG = "CalendarDataSource"
}
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource( internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
@@ -10,4 +11,6 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(), accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR), color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0, isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
) )

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant import kotlin.time.Instant
@@ -10,7 +11,20 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>> fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail 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) : class NoSuchEventException(eventId: Long) :
NoSuchElementException("No event with id=$eventId") 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")

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -68,6 +69,18 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) 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 { private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -38,7 +39,20 @@ class CalendarPrefs @Inject constructor(
} }
} }
/**
* 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 { companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -67,10 +68,38 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name } 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 { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
} }
} }

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen 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.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen
@@ -66,6 +67,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
var showSettings by rememberSaveable { mutableStateOf(false) } var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true } 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() val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
@@ -75,12 +85,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
onSelectView = onSelectView, onSelectView = onSelectView,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = view, selectedView = view,
onSelectView = onSelectView, onSelectView = onSelectView,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
@@ -88,6 +100,7 @@ fun CalendarHost(modifier: Modifier = Modifier) {
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
) )
} }
@@ -108,6 +121,20 @@ fun CalendarHost(modifier: Modifier = Modifier) {
} }
} }
// 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. // Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility( AnimatedVisibility(
visible = showSettings, visible = showSettings,

View File

@@ -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),
)
}
}
}

View File

@@ -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
},
)
}
}
}
}
}

View File

@@ -1,11 +1,8 @@
package de.jeanlucmakiola.calendula.ui.day package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
@@ -28,12 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -72,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -108,6 +104,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -144,7 +141,15 @@ fun DayScreen(
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() } val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() } // 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -172,17 +177,12 @@ fun DayScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( CalendarFabColumn(
visible = !isOnToday, todayVisible = !isOnToday,
enter = scaleIn(), todayText = stringResource(R.string.day_today_action),
exit = scaleOut(), onToday = jumpToToday,
) { onCreate = { onCreateEvent(date) },
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.day_today_action)) },
) )
}
}, },
) { innerPadding -> ) { innerPadding ->
DayContent( DayContent(

View File

@@ -1,11 +1,15 @@
package de.jeanlucmakiola.calendula.ui.detail package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.icu.text.ListFormatter import android.icu.text.ListFormatter
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -31,31 +35,38 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -81,6 +92,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -94,10 +106,10 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Read-only full-screen event detail (spec S4, realised as a navigation * Full-screen event detail (spec S4, realised as a navigation destination
* destination rather than a bottom sheet — MD3 list→detail pattern). Back * rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
* gesture and the top-bar arrow both return to the calendar. The only action is * top-bar arrow both return to the calendar. Events in writable calendars can
* tapping the location to open a maps intent. * be deleted from here (v1.1); edit follows in v1.3.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -112,10 +124,55 @@ fun EventDetailScreen(
viewModel.open(eventId, beginMillis, endMillis) viewModel.open(eventId, beginMillis, endMillis)
} }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack) 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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
@@ -128,18 +185,20 @@ fun EventDetailScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { /* TODO: edit event (V2) */ }) { // Only writable calendars get actions — WebCal subscriptions,
Icon( // birthday calendars etc. are read-only at the provider level.
imageVector = Icons.Default.Edit, val s = state
contentDescription = stringResource(R.string.event_detail_edit), if (s is EventDetailUiState.Success && s.canModify) {
) IconButton(
} onClick = onDeleteClick,
IconButton(onClick = { /* TODO: delete event (V2) */ }) { enabled = deleteState != DeleteUiState.Deleting,
) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete), contentDescription = stringResource(R.string.event_detail_delete),
) )
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@@ -159,8 +218,76 @@ fun EventDetailScreen(
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier) 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 @Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) { private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
val detail = state.detail val detail = state.detail

View File

@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
/** /**
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1. * UI state for the event-detail screen (spec S4).
*/ */
sealed interface EventDetailUiState { sealed interface EventDetailUiState {
data object Loading : EventDetailUiState data object Loading : EventDetailUiState
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
val detail: EventDetail, val detail: EventDetail,
/** Display name of the owning calendar, null if it can't be resolved. */ /** Display name of the owning calendar, null if it can't be resolved. */
val calendarName: String?, val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
) : EventDetailUiState ) : 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
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -38,6 +40,9 @@ class EventDetailViewModel @Inject constructor(
// Bumped by retry() to re-run the load for the same target. // Bumped by retry() to re-run the load for the same target.
private val _reload = MutableStateFlow(0) private val _reload = MutableStateFlow(0)
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<EventDetailUiState> = val state: StateFlow<EventDetailUiState> =
combine(_target, _reload) { target, _ -> target } combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target -> .flatMapLatest { target ->
@@ -72,6 +77,38 @@ class EventDetailViewModel @Inject constructor(
_reload.value += 1 _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 { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId) val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this // The Events row holds the series start; replace it with this
@@ -82,10 +119,13 @@ class EventDetailViewModel @Inject constructor(
end = Instant.fromEpochMilliseconds(target.endMillis), end = Instant.fromEpochMilliseconds(target.endMillis),
), ),
) )
val calendarName = repository.calendars().first() val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId } .firstOrNull { it.id == corrected.instance.calendarId }
?.displayName EventDetailUiState.Success(
EventDetailUiState.Success(corrected, calendarName) detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: NoSuchEventException) { } catch (e: NoSuchEventException) {

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -23,13 +20,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -62,6 +57,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -74,8 +70,11 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
@@ -86,6 +85,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
@@ -113,8 +113,14 @@ fun MonthScreen(
slideDir = -1 slideDir = -1
viewModel.goToPrev() viewModel.goToPrev()
} }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = { val jumpToToday = {
slideDir = 0 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() viewModel.goToToday()
} }
@@ -147,17 +153,20 @@ fun MonthScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( CalendarFabColumn(
visible = !isOnCurrentMonth, todayVisible = !isOnCurrentMonth,
enter = scaleIn(), todayText = stringResource(R.string.month_today_action),
exit = scaleOut(), onToday = jumpToToday,
) { onCreate = {
ExtendedFloatingActionButton( // Anchor on today when its month is shown, else the 1st.
onClick = jumpToToday, val today = Clock.System.now()
icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, .toLocalDateTime(TimeZone.currentSystemDefault()).date
text = { Text(stringResource(R.string.month_today_action)) }, onCreateEvent(
if (isOnCurrentMonth) today
else LocalDate(month.year, month.month, 1),
)
},
) )
}
}, },
) { innerPadding -> ) { innerPadding ->
Column( Column(

View File

@@ -56,6 +56,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R 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. // MD3 8dp spacing scale, scoped to this screen.
private object Space { private object Space {
val xs = 8.dp val xs = 8.dp
@@ -73,10 +78,17 @@ fun PermissionScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() 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( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestMultiplePermissions(),
) { granted -> ) { results ->
if (granted) viewModel.onGranted() else viewModel.onDenied() if (results[Manifest.permission.READ_CALENDAR] == true) {
viewModel.onGranted()
} else {
viewModel.onDenied()
}
} }
LaunchedEffect(state) { LaunchedEffect(state) {
@@ -85,13 +97,13 @@ fun PermissionScreen(
when (state) { when (state) {
is PermissionUiState.Rationale -> RationaleContent( is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) }, onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Denied -> DeniedContent( is PermissionUiState.Denied -> DeniedContent(
onRetry = { onRetry = {
viewModel.onRetry() viewModel.onRetry()
launcher.launch(Manifest.permission.READ_CALENDAR) launcher.launch(CALENDAR_PERMISSIONS)
}, },
modifier = modifier, modifier = modifier,
) )

View File

@@ -47,6 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/** /**
* Settings (M4) — appearance (theme, dynamic colour, week start), language, * Settings (M4) — appearance (theme, dynamic colour, week start), language,
@@ -111,6 +112,22 @@ fun SettingsScreen(
onSelect = viewModel::setWeekStart, 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)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language)) SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow() LanguageRow()
@@ -298,6 +315,35 @@ private fun AboutRow(title: String, value: String) {
} }
} }
@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 @Composable
private fun themeLabel(mode: ThemeMode): String = stringResource( private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) { when (mode) {

View File

@@ -1,7 +1,9 @@
package de.jeanlucmakiola.calendula.ui.settings 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.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -14,4 +16,6 @@ data class SettingsUiState(
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true, val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO, val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
) )

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -26,12 +27,14 @@ class SettingsViewModel @Inject constructor(
prefs.themeMode, prefs.themeMode,
prefs.dynamicColor, prefs.dynamicColor,
prefs.weekStart, prefs.weekStart,
) { theme, dynamic, weekStart -> prefs.defaultFormFields,
) { theme, dynamic, weekStart, formFields ->
SettingsUiState( SettingsUiState(
themeMode = theme, themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable, dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable, dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart, weekStart = weekStart,
defaultFormFields = formFields,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -50,4 +53,8 @@ class SettingsViewModel @Inject constructor(
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) } viewModelScope.launch { prefs.setWeekStart(pref) }
} }
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
} }

View File

@@ -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,
) )
} }

View File

@@ -1,11 +1,8 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
@@ -31,12 +28,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -77,6 +72,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -88,7 +84,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -113,6 +112,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), viewModel: WeekViewModel = hiltViewModel(),
) { ) {
@@ -146,7 +146,15 @@ fun WeekScreen(
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() } val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() } // 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -174,17 +182,17 @@ fun WeekScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( CalendarFabColumn(
visible = !isOnCurrentWeek, todayVisible = !isOnCurrentWeek,
enter = scaleIn(), todayText = stringResource(R.string.week_today_action),
exit = scaleOut(), onToday = jumpToToday,
) { onCreate = {
ExtendedFloatingActionButton( // Anchor on today when it's in view, else the week's first day.
onClick = jumpToToday, val today = Clock.System.now()
icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, .toLocalDateTime(TimeZone.currentSystemDefault()).date
text = { Text(stringResource(R.string.week_today_action)) }, onCreateEvent(if (isOnCurrentWeek) today else weekStart)
},
) )
}
}, },
) { innerPadding -> ) { innerPadding ->
WeekContent( WeekContent(

View File

@@ -13,7 +13,7 @@
<!-- Permission-Flow (F1) --> <!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string> <string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.</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_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</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_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
@@ -25,7 +25,7 @@
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</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_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string> <string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Nur Lesezugriff · keine Internet-Berechtigung</string> <string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) --> <!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string> <string name="month_prev">Vorheriger Monat</string>
@@ -45,8 +45,42 @@
<!-- Event-Detail-Screen (S4) --> <!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string> <string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string> <string name="event_detail_delete">Löschen</string>
<string name="event_delete_title">Termin löschen?</string>
<string name="event_delete_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_all_day">Ganztägig</string>
<string name="event_detail_calendar">Kalender</string> <string name="event_detail_calendar">Kalender</string>
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string> <string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
@@ -129,6 +163,8 @@
<string name="settings_week_start_auto">Automatisch</string> <string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string> <string name="settings_week_start_monday">Montag</string>
<string name="settings_week_start_sunday">Sonntag</string> <string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_section_language">Sprache</string> <string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string> <string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string> <string name="settings_language_auto">Systemstandard</string>

View File

@@ -14,7 +14,7 @@
<!-- Permission flow (F1) --> <!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string> <string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.</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_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</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_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
@@ -26,7 +26,7 @@
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</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_title">No tracking, ever</string>
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string> <string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
<string name="permission_privacy_footnote">Read-only · no internet permission</string> <string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
<!-- Month view (S1) --> <!-- Month view (S1) -->
<string name="month_prev">Previous month</string> <string name="month_prev">Previous month</string>
@@ -46,8 +46,42 @@
<!-- Event detail screen (S4) --> <!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string> <string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string> <string name="event_detail_delete">Delete</string>
<string name="event_delete_title">Delete event?</string>
<string name="event_delete_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_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string> <string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string> <string name="event_detail_calendar_unknown">Unknown calendar</string>
@@ -130,6 +164,8 @@
<string name="settings_week_start_auto">Automatic</string> <string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_start_monday">Monday</string> <string name="settings_week_start_monday">Monday</string>
<string name="settings_week_start_sunday">Sunday</string> <string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_section_language">Language</string> <string name="settings_section_language">Language</string>
<string name="settings_language">App language</string> <string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string> <string name="settings_language_auto">System default</string>

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -12,6 +13,7 @@ class CalendarMapperTest {
accountType: String? = "LOCAL", accountType: String? = "LOCAL",
color: Int = 0, color: Int = 0,
visible: Int = 1, visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id, CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName, CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -19,6 +21,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_ACCOUNT_TYPE to accountType, CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
CalendarProjection.IDX_COLOR to color, CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible, CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
) )
@Test @Test
@@ -39,6 +42,7 @@ class CalendarMapperTest {
accountType = "com.google", accountType = "com.google",
color = 0xFF112233.toInt(), color = 0xFF112233.toInt(),
isVisibleInSystem = true, isVisibleInSystem = true,
canModifyContents = true,
) )
) )
} }
@@ -65,4 +69,25 @@ class CalendarMapperTest {
assertThat(src.accountName).isEqualTo("") assertThat(src.accountName).isEqualTo("")
assertThat(src.accountType).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()
}
} }

View File

@@ -7,8 +7,12 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -157,6 +161,80 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val 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 @Test
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest { fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {

View File

@@ -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)
}
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
/** /**
@@ -13,6 +14,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList() var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() } var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null } 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>() private val listeners = mutableListOf<() -> Unit>()
@@ -20,6 +29,22 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> = override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis) instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) 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) { override fun registerChangeListener(listener: () -> Unit) {
listeners += listener listeners += listener
} }

View File

@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -60,6 +61,45 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) 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 @Test
fun `explicit week-start prefs resolve regardless of locale`() { fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -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,
)
}
}

View 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