5 Commits

Author SHA1 Message Date
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
3697a58e5b release: cut v1.0.0 — first public release
Some checks failed
CI / ci (push) Successful in 13m23s
CI / ci (pull_request) Has been cancelled
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
Version bumped to 1.0.0 / 7. No code changes beyond the version — 1.0.0 is the
accumulated v0.1 → v0.6 work (all V1 screens, full event read, filter, settings,
onboarding polish) declared release-ready. CHANGELOG [1.0.0] summarises the
shipped feature set; ROADMAP/STATE mark V1 complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:24:47 +02:00
23 changed files with 635 additions and 72 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

@@ -10,7 +10,7 @@
| v0.4 | Event Detail (S4) + humanized recurrence | complete | | v0.4 | Event Detail (S4) + humanized recurrence | complete |
| v0.5 | Calendar filter (M3) + Settings (M4) | complete | | v0.5 | Calendar filter (M3) + Settings (M4) | complete |
| v0.6 | Full event read — surface every readable field | complete | | v0.6 | Full event read — surface every readable field | complete |
| v1.0 | Polish pass, F-Droid release | pending | | v1.0 | First public release — polish pass, F-Droid | complete |
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5. Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
@@ -44,20 +44,27 @@ Deliberately out of v0.6:
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract` - `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice) (provider limitation, not our choice)
## v1.0 — First Public Release ## v1.0 — First Public Release — shipped 2026-06-11
All V1 features shipped, polished, on F-Droid. Read-only calendar. All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
Remaining before v1.0: a UI polish/QA pass. after v0.6 (full event read) plus the onboarding-screen polish pass.
### Polish backlog (pre-1.0) ### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done** - ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped on the v0.6.0 branch) (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, default-calendar pref | planned |
| 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,10 @@
## Status ## Status
**Milestone:** v0.6 — Full event read (complete) **Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** All V1 screens done and the read model is now complete — the detail **Phase:** v1.1.0 shipped 2026-06-11 (write foundation + delete). Milestone 2
view surfaces every readable `CalendarContract` field. Next up is a UI runs in four slices (`docs/superpowers/plans/2026-06-11-03-write-support.md`);
polish/QA pass before v1.0 next up is v1.2 (create event).
## Progress ## Progress
@@ -28,7 +28,14 @@ polish/QA pass before v1.0
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
## Next ## Next
1. UI polish / QA pass across all views before v1.0 1. v1.2 — create event: form screen, FAB, default-calendar pref, `insertEvent`
2. F-Droid release of v1.0 2. Monitor the F-Droid build/publish for v1.1.0

View File

@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.1.0] — 2026-06-11
### Added
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
- Delete action on the event detail screen, with a confirmation dialog;
recurring events choose between "Only this event" (a cancelled exception,
so the rest of the series survives) and "All events in the series"
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
dialog, but only read access is required — declining write keeps the app
fully usable read-only. Existing v1.0 installs are asked for the write
upgrade in place, on their first delete
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
### Changed
- Onboarding copy no longer claims "read-only"; it now says your data stays on
the device (still no internet permission, still zero telemetry)
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
removed until editing ships in a later slice
- `versionName`/`versionCode` bumped to 1.1.0 / 8
## [1.0.0] — 2026-06-11
First public release. Calendula is a read-only, Material 3 Expressive calendar
that lives entirely on top of Android's `CalendarContract` — every calendar
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
automatically, with zero telemetry and no internet permission.
### Highlights (accumulated across v0.1 → v0.6)
- Month, week, and day views with a view switcher, swipe navigation, and
Loading / Failure / Success states on every screen
- Full-screen event detail surfacing every readable `CalendarContract` field —
times, recurrence (humanised), location, description (with tappable links),
attendees + roles + your own response, reminders, status, availability,
access level, and foreign time zones
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
screen (theme, Material You dynamic colour, week start, app language)
- Material 3 Expressive first-run onboarding for calendar access
- German + English localization throughout
### Changed
- `versionName`/`versionCode` bumped to 1.0.0 / 7
## [0.6.0] — 2026-06-11 ## [0.6.0] — 2026-06-11
### Added ### Added

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 8
versionName = "0.6.0" versionName = "1.1.0"
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"

View File

@@ -2,6 +2,7 @@ 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
@@ -29,6 +30,16 @@ 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?
/** 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 +85,28 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
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) {

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

@@ -10,7 +10,17 @@ 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
/** 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

@@ -68,6 +68,14 @@ 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 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

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

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

@@ -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
@@ -24,6 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape 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
@@ -31,31 +36,40 @@ 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.RadioButton
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.compose.ui.semantics.Role
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
@@ -94,10 +108,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 +126,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,17 +187,19 @@ 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( ) {
imageVector = Icons.Default.Delete, Icon(
contentDescription = stringResource(R.string.event_detail_delete), imageVector = Icons.Default.Delete,
) contentDescription = stringResource(R.string.event_detail_delete),
)
}
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -159,6 +220,88 @@ 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 {
DeleteChoiceRow(
selected = !wholeSeries,
label = stringResource(R.string.event_delete_option_occurrence),
onSelect = { wholeSeries = false },
)
DeleteChoiceRow(
selected = wholeSeries,
label = stringResource(R.string.event_delete_option_series),
onSelect = { wholeSeries = true },
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun DeleteChoiceRow(selected: Boolean, label: String, onSelect: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, role = Role.RadioButton, onClick = onSelect)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = null)
Spacer(Modifier.width(8.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
} }
@Composable @Composable

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

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

@@ -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,15 @@
<!-- Event-Detail-Screen (S4) --> <!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string> <string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string> <string name="event_detail_delete">Löschen</string>
<string name="event_delete_title">Termin löschen?</string>
<string name="event_delete_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="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>

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,15 @@
<!-- Event detail screen (S4) --> <!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string> <string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string> <string name="event_detail_delete">Delete</string>
<string name="event_delete_title">Delete event?</string>
<string name="event_delete_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="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>

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

@@ -157,6 +157,43 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
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

@@ -13,6 +13,11 @@ 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
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>() private val listeners = mutableListOf<() -> Unit>()
@@ -20,6 +25,16 @@ 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 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

@@ -0,0 +1,106 @@
# 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) | in Arbeit |
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | offen |
| 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 (Skizze)
- `EventForm`-Domain-Modell + Validierung (Ende > Start, Titel-Fallback)
- `EventEditScreen` (ein Formular für Create+Edit), M3-Date/Time-Picker
- FAB auf allen drei Hauptansichten, vorbelegt mit sichtbarem Tag/Slot
- `CalendarPrefs.defaultCalendarId` + Auswahl im Formular (nur beschreibbare
Kalender anbieten)
- `insertEvent(form): Long` im DataSource (`DTSTART/DTEND/EVENT_TIMEZONE`,
all-day in UTC)
## 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