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:
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user