diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 1f5e269..3c2aa11 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -53,11 +53,18 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(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
-- Quick-add sheet
-- Conflict UX (event modified externally during edit)
+Delivered in four releasable slices (plan:
+`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
+guide here, not a contract — scope per slice is decided as we go.
+
+| Version | Milestone | Status |
+|---|---|---|
+| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | in progress |
+| 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
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 5673f74..29f0582 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -4,10 +4,10 @@
## Status
-**Milestone:** v1.0.0 — First public release (shipped 2026-06-11)
-**Phase:** V1 is complete and released. All screens done, the read model
-surfaces every readable `CalendarContract` field, and the onboarding screen
-got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
+**Milestone:** v2.0 — Write support (milestone 2, in progress)
+**Phase:** v1.0.0 shipped 2026-06-11. Milestone 2 is planned in four slices
+(`docs/superpowers/plans/2026-06-11-03-write-support.md`); slice v1.1
+(write foundation + delete) is implemented and awaiting release.
## Progress
@@ -28,7 +28,15 @@ 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
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
-1. v1.0.0 released — monitor the F-Droid build/publish
-2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX)
+1. Cut v1.1 release (changelog entry is staged under [Unreleased])
+2. v1.2 — create event: form screen, FAB, default-calendar pref, `insertEvent`
+3. v1.0.0 — keep monitoring the F-Droid build/publish
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25e0f61..2c1426b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### 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
+
## [1.0.0] — 2026-06-11
First public release. Calendula is a read-only, Material 3 Expressive calendar
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9d41606..f4a1c66 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
fun instances(beginMillis: Long, endMillis: Long): List
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 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) {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt
index 7531308..1e2c2f0 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
+import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
@@ -10,4 +11,6 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
+ canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
+ CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
index 967bea2..b9836fa 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
@@ -10,7 +10,17 @@ interface CalendarRepository {
fun calendars(): Flow>
fun instances(range: ClosedRange): Flow>
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) :
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")
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index 6a87154..f9b33be 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -68,6 +68,14 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
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 Flow.reQuery(block: suspend () -> T): Flow = flow {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
index 65943d1..5d66215 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
@@ -10,6 +10,7 @@ internal object CalendarProjection {
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
+ CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
)
const val IDX_ID = 0
@@ -18,6 +19,7 @@ internal object CalendarProjection {
const val IDX_ACCOUNT_TYPE = 3
const val IDX_COLOR = 4
const val IDX_VISIBLE = 5
+ const val IDX_ACCESS_LEVEL = 6
}
internal object InstanceProjection {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
index fdb135c..67b8559 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
@@ -9,6 +9,12 @@ data class CalendarSource(
val accountType: String,
val color: Int,
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(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt
index 6d5fb6c..155d3e7 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt
@@ -1,11 +1,15 @@
package de.jeanlucmakiola.calendula.ui.detail
+import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.icu.text.ListFormatter
import android.net.Uri
import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -24,6 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.filled.CalendarMonth
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.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.Role
+import androidx.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -94,10 +108,10 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
- * Read-only full-screen event detail (spec S4, realised as a navigation
- * destination rather than a bottom sheet — MD3 list→detail pattern). Back
- * gesture and the top-bar arrow both return to the calendar. The only action is
- * tapping the location to open a maps intent.
+ * Full-screen event detail (spec S4, realised as a navigation destination
+ * rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
+ * top-bar arrow both return to the calendar. Events in writable calendars can
+ * be deleted from here (v1.1); edit follows in v1.3.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -112,10 +126,55 @@ fun EventDetailScreen(
viewModel.open(eventId, beginMillis, endMillis)
}
val state by viewModel.state.collectAsStateWithLifecycle()
+ val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
+ val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
+
+ // v1.0 installs only hold READ_CALENDAR; the first write asks for the
+ // upgrade in place. Granting continues straight into the confirm dialog.
+ val writePermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ if (granted) showDeleteDialog = true
+ }
+ val onDeleteClick = {
+ val granted = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.WRITE_CALENDAR,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ showDeleteDialog = true
+ } else {
+ writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
+ }
+ }
+
+ val deleteFailedMessage = stringResource(R.string.event_delete_failed)
+ val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
+ LaunchedEffect(deleteState) {
+ when (deleteState) {
+ DeleteUiState.Deleted -> {
+ viewModel.consumeDeleteResult()
+ onBack()
+ }
+ DeleteUiState.Failed -> {
+ viewModel.consumeDeleteResult()
+ snackbarHostState.showSnackbar(deleteFailedMessage)
+ }
+ DeleteUiState.NeedsPermission -> {
+ viewModel.consumeDeleteResult()
+ snackbarHostState.showSnackbar(writeDeniedMessage)
+ }
+ DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
+ }
+ }
+
Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {},
@@ -128,17 +187,19 @@ fun EventDetailScreen(
}
},
actions = {
- IconButton(onClick = { /* TODO: edit event (V2) */ }) {
- Icon(
- imageVector = Icons.Default.Edit,
- contentDescription = stringResource(R.string.event_detail_edit),
- )
- }
- IconButton(onClick = { /* TODO: delete event (V2) */ }) {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = stringResource(R.string.event_detail_delete),
- )
+ // Only writable calendars get actions — WebCal subscriptions,
+ // birthday calendars etc. are read-only at the provider level.
+ val s = state
+ if (s is EventDetailUiState.Success && s.canModify) {
+ IconButton(
+ onClick = onDeleteClick,
+ enabled = deleteState != DeleteUiState.Deleting,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = stringResource(R.string.event_detail_delete),
+ )
+ }
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -159,6 +220,88 @@ fun EventDetailScreen(
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
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt
index 725cce1..60d405b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt
@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
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 {
data object Loading : EventDetailUiState
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
val detail: EventDetail,
/** Display name of the owning calendar, null if it can't be resolved. */
val calendarName: String?,
+ /** Whether the owning calendar allows modifying events (shows edit/delete). */
+ val canModify: Boolean = false,
) : EventDetailUiState
}
+
+/**
+ * One-shot state of a delete request, separate from the screen state so a
+ * failed delete leaves the loaded detail visible.
+ */
+sealed interface DeleteUiState {
+ data object Idle : DeleteUiState
+ data object Deleting : DeleteUiState
+ data object Deleted : DeleteUiState
+ /** WRITE_CALENDAR was revoked between the tap and the provider call. */
+ data object NeedsPermission : DeleteUiState
+ data object Failed : DeleteUiState
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
index c7282c5..e8a709a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
@@ -38,6 +40,9 @@ class EventDetailViewModel @Inject constructor(
// Bumped by retry() to re-run the load for the same target.
private val _reload = MutableStateFlow(0)
+ private val _deleteState = MutableStateFlow(DeleteUiState.Idle)
+ val deleteState: StateFlow = _deleteState.asStateFlow()
+
val state: StateFlow =
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
@@ -72,6 +77,38 @@ class EventDetailViewModel @Inject constructor(
_reload.value += 1
}
+ /**
+ * Delete the open event. [wholeSeries] is meaningful only for recurring
+ * events: false cancels just the tapped occurrence. Result lands in
+ * [deleteState]; the screen consumes it via [consumeDeleteResult].
+ */
+ fun delete(wholeSeries: Boolean) {
+ val target = _target.value ?: return
+ if (_deleteState.value == DeleteUiState.Deleting) return
+ viewModelScope.launch {
+ _deleteState.value = DeleteUiState.Deleting
+ _deleteState.value = try {
+ if (wholeSeries) {
+ repository.deleteEvent(target.eventId)
+ } else {
+ repository.deleteOccurrence(target.eventId, target.beginMillis)
+ }
+ DeleteUiState.Deleted
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: SecurityException) {
+ DeleteUiState.NeedsPermission
+ } catch (e: Exception) {
+ DeleteUiState.Failed
+ }
+ }
+ }
+
+ /** Reset [deleteState] after the screen handled a terminal result. */
+ fun consumeDeleteResult() {
+ _deleteState.value = DeleteUiState.Idle
+ }
+
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
@@ -82,10 +119,13 @@ class EventDetailViewModel @Inject constructor(
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
- val calendarName = repository.calendars().first()
+ val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
- ?.displayName
- EventDetailUiState.Success(corrected, calendarName)
+ EventDetailUiState.Success(
+ detail = corrected,
+ calendarName = calendar?.displayName,
+ canModify = calendar?.canModifyContents == true,
+ )
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
index d53b008..7f25b2f 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
@@ -56,6 +56,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
+private val CALENDAR_PERMISSIONS = arrayOf(
+ Manifest.permission.READ_CALENDAR,
+ Manifest.permission.WRITE_CALENDAR,
+)
+
// MD3 8dp spacing scale, scoped to this screen.
private object Space {
val xs = 8.dp
@@ -73,10 +78,17 @@ fun PermissionScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ // READ and WRITE are requested together (one system dialog — same
+ // permission group), but only READ gates the app: declining write keeps
+ // Calendula usable read-only.
val launcher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.RequestPermission(),
- ) { granted ->
- if (granted) viewModel.onGranted() else viewModel.onDenied()
+ contract = ActivityResultContracts.RequestMultiplePermissions(),
+ ) { results ->
+ if (results[Manifest.permission.READ_CALENDAR] == true) {
+ viewModel.onGranted()
+ } else {
+ viewModel.onDenied()
+ }
}
LaunchedEffect(state) {
@@ -85,13 +97,13 @@ fun PermissionScreen(
when (state) {
is PermissionUiState.Rationale -> RationaleContent(
- onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
+ onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
modifier = modifier,
)
is PermissionUiState.Denied -> DeniedContent(
onRetry = {
viewModel.onRetry()
- launcher.launch(Manifest.permission.READ_CALENDAR)
+ launcher.launch(CALENDAR_PERMISSIONS)
},
modifier = modifier,
)
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 6603370..a19c62f 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -13,7 +13,7 @@
Alle Termine, schön im Blick
- Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.
+ Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.
Kalender-Zugriff erlauben
Kalender-Zugriff abgelehnt
Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.
@@ -25,7 +25,7 @@
Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.
Kein Tracking, niemals
Keine Telemetrie, keine Analyse, keine Werbung.
- Nur Lesezugriff · keine Internet-Berechtigung
+ Bleibt auf deinem Gerät · keine Internet-Berechtigung
Vorheriger Monat
@@ -45,8 +45,15 @@
Zurück
- Bearbeiten
Löschen
+ Termin löschen?
+ Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.
+ Wiederkehrenden Termin löschen
+ Nur dieser Termin
+ Alle Termine der Serie
+ Termin konnte nicht gelöscht werden
+ Calendula braucht Schreibzugriff, um Termine zu löschen
+ Abbrechen
Ganztägig
Kalender
Unbekannter Kalender
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f632463..0f9fa00 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,7 +14,7 @@
See all your events, beautifully
- Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.
+ Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.
Grant calendar access
Calendar access denied
Calendula cannot show events without calendar access. You can grant it again in the system settings.
@@ -26,7 +26,7 @@
Google, CalDAV, local — anything synced to the device just appears.
No tracking, ever
Zero telemetry, zero analytics, no ads.
- Read-only · no internet permission
+ Stays on your device · no internet permission
Previous month
@@ -46,8 +46,15 @@
Back
- Edit
Delete
+ Delete event?
+ The event is removed from your calendar and from every device it syncs to.
+ Delete recurring event
+ Only this event
+ All events in the series
+ Couldn\'t delete the event
+ Calendula needs write access to delete events
+ Cancel
All day
Calendar
Unknown calendar
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt
index 4aaf42b..d5e0ec3 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
+import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
@@ -12,6 +13,7 @@ class CalendarMapperTest {
accountType: String? = "LOCAL",
color: Int = 0,
visible: Int = 1,
+ accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -19,6 +21,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible,
+ CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
)
@Test
@@ -39,6 +42,7 @@ class CalendarMapperTest {
accountType = "com.google",
color = 0xFF112233.toInt(),
isVisibleInSystem = true,
+ canModifyContents = true,
)
)
}
@@ -65,4 +69,25 @@ class CalendarMapperTest {
assertThat(src.accountName).isEqualTo("")
assertThat(src.accountType).isEqualTo("")
}
+
+ @Test
+ fun `contributor access and above can modify contents`() {
+ val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
+ val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
+ assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
+ assertThat(owner.toCalendarSource().canModifyContents).isTrue()
+ }
+
+ @Test
+ fun `read access cannot modify contents`() {
+ val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
+ assertThat(src.toCalendarSource().canModifyContents).isFalse()
+ }
+
+ @Test
+ fun `missing access level defaults to read-only`() {
+ // WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
+ val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
+ assertThat(src.toCalendarSource().canModifyContents).isFalse()
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
index 05b909d..1d71cb7 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
@@ -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
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
index 7c98cd7..bca4b03 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
@@ -13,6 +13,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List = emptyList()
var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
+ /** Set to make the next write call throw. */
+ var writeError: Exception? = null
+
+ val deletedEventIds = mutableListOf()
+ val deletedOccurrences = mutableListOf>()
private val listeners = mutableListOf<() -> Unit>()
@@ -20,6 +25,16 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List =
instancesResult(beginMillis, endMillis)
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) {
listeners += listener
}
diff --git a/docs/superpowers/plans/2026-06-11-03-write-support.md b/docs/superpowers/plans/2026-06-11-03-write-support.md
new file mode 100644
index 0000000..3c50b57
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-03-write-support.md
@@ -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