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>
This commit is contained in:
2026-06-11 12:55:15 +02:00
parent 0013c9f3b1
commit 9529f19c60
20 changed files with 544 additions and 41 deletions

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<application
android:name=".CalendulaApp"

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
@@ -29,6 +30,16 @@ interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
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) {

View File

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

View File

@@ -10,7 +10,17 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
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")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<EventDetailUiState> =
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) {

View File

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

View File

@@ -13,7 +13,7 @@
<!-- Permission-Flow (F1) -->
<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_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>
@@ -25,7 +25,7 @@
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Nur Lesezugriff · keine Internet-Berechtigung</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
@@ -45,8 +45,15 @@
<!-- Event-Detail-Screen (S4) -->
<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_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_calendar">Kalender</string>
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>

View File

@@ -14,7 +14,7 @@
<!-- Permission flow (F1) -->
<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_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>
@@ -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_privacy_title">No tracking, ever</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) -->
<string name="month_prev">Previous month</string>
@@ -46,8 +46,15 @@
<!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</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_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>

View File

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

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
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {

View File

@@ -13,6 +13,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
/** Set to make the next write call throw. */
var writeError: Exception? = null
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>()
@@ -20,6 +25,16 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
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
}