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