Compare commits

..

4 Commits

Author SHA1 Message Date
f81f593017 fix(edit): curate the CalDAV colour picker (#22)
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the event-colour picker showed a full screen
of alphabetically-scrambled, partly duplicated swatches.

Curation now runs in the space the picker actually paints — every swatch
is softened through pastelize, which pins lightness and caps saturation,
so the raw palette's lightness axis is invisible on screen. Judging
distinctness there: colours that paint identically collapse to one
(folding aliases, dark/light shades of a hue, and the neutrals together),
oversized palettes drop washed-out neutral-origin tints and thin by CIE76
ΔE in painted Lab, and survivors sort continuously by painted hue with the
wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct,
rainbow-ordered swatches; small hand-picked palettes (Google's) pass
through untouched. Every surviving swatch keeps its provider colour key so
picks still round-trip through sync.

This revives work stranded on fix/caldav-color-picker (never merged) and
adapts it to the floret-kit extraction of pastelize: the curation's
painted-space transform now lives self-contained in domain/pastelArgb as a
mirror of floret's pastelize shaping, rather than the two sharing one
function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:20:07 +02:00
8ddc11159b Merge pull request 'Translations update from Weblate' (!66) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m8s
Reviewed-on: #66
2026-07-12 08:50:03 +00:00
Kachelkaiser
6736bae5ee Translated using Weblate (German)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m17s
Currently translated at 100.0% (424 of 424 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-10 23:01:07 +00:00
Sam Gamgee
21d1726d7a Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m38s
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-07 14:42:36 +00:00
13 changed files with 95 additions and 483 deletions

View File

@@ -97,74 +97,32 @@
<data android:mimeType="time/epoch" />
</intent-filter>
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
The three MIME types cover the common labels the same calendar
data arrives under: iCalendar 2.0 (text/calendar), the older
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
mail apps emit — Android cross-products the scheme and mimeType
tags, so each MIME is accepted on both schemes (matches Etar). -->
<!-- Open a .ics file (file manager / email attachment / browser). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
<data android:scheme="content" android:mimeType="text/calendar" />
<data android:scheme="file" android:mimeType="text/calendar" />
</intent-filter>
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<!-- Receive a .ics shared from another app. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new
event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. -->
(MainActivity.insertFormOrNull, issue #30). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<!-- Edit an existing event another app/assistant/widget points at:
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
AOSP fires it. Opens the occurrence in the edit form (not the
read-only detail — that's the VIEW filter above). Matched by the
provider's item MIME type, like the VIEW filter. The occurrence's
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
supplied (MainActivity.editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it.
Matched by the provider's item MIME type, not the path — a
content: VIEW intent carries the resolved type
(vnd.android.cursor.item/event) and a path-only filter wouldn't
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when the launcher supplies them
(MainActivity.viewEventKeyOrNull, issue #48). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"

View File

@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -70,11 +69,6 @@ class MainActivity : AppCompatActivity() {
// CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -96,11 +90,10 @@ class MainActivity : AppCompatActivity() {
}
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -151,8 +144,6 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
}
// A persistent corner marker so a debug build is never
@@ -187,11 +178,10 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
/**
@@ -212,19 +202,13 @@ class MainActivity : AppCompatActivity() {
}
/**
* A prefilled new-event form from an external launch asking us to create an
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm].
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
* The new event's fields ride as CalendarContract extras; anything omitted
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
*/
private fun Intent.insertFormOrNull(): EventForm? {
// ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
if (!isCreate) return null
if (action != Intent.ACTION_INSERT) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
@@ -309,55 +293,6 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* The detail key for an external "open this event" — ACTION_VIEW on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
* same occurrence-key channel as reminder taps. The launcher passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
* rather than rendering at the epoch.
*/
private fun Intent.viewEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
/**
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* an assistant, task app, or widget that wants to open the event for editing
* rather than viewing). Opens it in the edit form. Reuses the same
* occurrence-key channel as reminder/view taps; the caller passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -495,13 +495,7 @@ class AndroidCalendarDataSource @Inject constructor(
return resolver.query(
uri,
InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
null,
null, null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
@@ -1192,25 +1186,15 @@ class AndroidCalendarDataSource @Inject constructor(
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. It has to
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
// the provider only demotes the cloned exception to a single instance —
// clearing the inherited RRULE — when it can derive that instance from
// those columns. A STATUS-only cancel left the RRULE standing and
// cancelled the whole series, wiping every other occurrence (#47), the
// same trap the edit path documents (Codeberg #16).
val row = querySeriesRow(eventId)
val values = buildOccurrenceCancelValues(
originalInstanceMillis = beginMillis,
dtStartMillis = beginMillis,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// 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.toContentValues())
resolver.insert(uri, values)
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
}

View File

@@ -182,33 +182,6 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
* Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
* provider clone the series row and cancel exactly this one instance.
*
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
* DTSTART + DURATION so the provider derives a single instance and clears the
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
* the *whole series* is cancelled and every other occurrence disappears
* (Codeberg #47). The occurrence's length/zone come straight from the series row
* — cancelling never changes them.
*/
internal fun buildOccurrenceCancelValues(
originalInstanceMillis: Long,
dtStartMillis: Long,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the

View File

@@ -33,7 +33,6 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
@@ -75,8 +74,6 @@ fun CalendarHost(
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
@@ -175,9 +172,6 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// Which channel filled [importForm]: an .ics file (prompt to apply the default
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
var importFormSource by remember { mutableStateOf(ImportSource.File) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
@@ -196,7 +190,6 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert
importForm = requestedInsertForm
onInsertConsumed()
}
@@ -214,20 +207,6 @@ fun CalendarHost(
importForm = null
}
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
onEditKeyConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
@@ -456,7 +435,6 @@ fun CalendarHost(
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null
importFormSource = ImportSource.File
importForm = form
},
)
@@ -465,7 +443,6 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = null,
initialForm = form,
initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
)

View File

@@ -37,8 +37,6 @@ fun RootScreen(
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -90,8 +88,6 @@ fun RootScreen(
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,

View File

@@ -141,20 +141,13 @@ class EventDetailViewModel @Inject constructor(
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
// occurrence's time so recurring events render correctly. An external
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
// own DTSTART/DTEND instead of overriding it to the epoch.
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
detail
} else {
detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
}
// occurrence's time so recurring events render correctly.
val corrected = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
EventDetailUiState.Success(
@@ -175,16 +168,6 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
companion object {
/**
* Sentinel begin/end for an "open this event" that names no occurrence —
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
* row's own DTSTART/DTEND instead of overriding it to the epoch.
*/
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
}
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */

View File

@@ -89,7 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
@@ -183,14 +182,12 @@ fun EventEditScreen(
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
when {
// A prefilled open: a single-event .ics for review, or an external
// ACTION_INSERT intent. The source drives how reminders are seeded.
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
// Single-event .ics open: the form arrives prefilled for review.
initialForm != null -> viewModel.openImported(initialForm)
editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
@@ -205,7 +202,6 @@ fun EventEditScreen(
}
val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
// The form is intentionally forgotten on every close (cancel or save) so
// the next open starts clean; it survives rotation because openNew /
@@ -336,56 +332,6 @@ fun EventEditScreen(
},
)
}
// A .ics import respects the file's reminders, but offers to swap in the
// configured default rather than silently deciding for the user (#49).
importReminderPrompt?.let { prompt ->
ImportReminderPromptDialog(
currentReminderCount = prompt.currentReminderCount,
onApply = viewModel::applyImportedReminderDefault,
onKeep = viewModel::dismissImportedReminderPrompt,
)
}
}
/**
* Offer to apply the settings default reminder to an event opened from a `.ics`
* file. The file's own reminders are kept unless the user accepts. A plain
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
*/
@Composable
private fun ImportReminderPromptDialog(
currentReminderCount: Int,
onApply: () -> Unit,
onKeep: () -> Unit,
) {
AlertDialog(
onDismissRequest = onKeep,
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
text = {
Text(
if (currentReminderCount == 0) {
stringResource(R.string.import_reminder_prompt_body_none)
} else {
pluralStringResource(
R.plurals.import_reminder_prompt_body_existing,
currentReminderCount,
currentReminderCount,
)
},
)
},
confirmButton = {
TextButton(onClick = onApply) {
Text(stringResource(R.string.import_reminder_prompt_apply))
}
},
dismissButton = {
TextButton(onClick = onKeep) {
Text(stringResource(R.string.import_reminder_prompt_keep))
}
},
)
}
/**

View File

@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -51,36 +50,6 @@ import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import javax.inject.Inject
/**
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
* sources want different reminder handling (#49).
*/
enum class ImportSource {
/**
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
* It carries no reminders of its own, so the settings default is applied
* automatically, exactly like an in-app new event.
*/
Insert,
/**
* A parsed single-event `.ics` file. Its own reminders are respected; the
* settings default is offered through [EventEditViewModel.importReminderPrompt]
* rather than silently applied or suppressed.
*/
File,
}
/**
* A pending offer to swap an imported `.ics` event's reminders for the settings
* default (#49). [currentReminderCount] is what the file carried (0 or more);
* [defaultReminders] is what accepting would set.
*/
data class ImportReminderPrompt(
val currentReminderCount: Int,
val defaultReminders: List<Int>,
)
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
@@ -109,16 +78,10 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
/**
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
@@ -149,17 +112,7 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
) {
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
allDayGlobal = allDay,
timedOverrides = timedOverrides,
allDayOverrides = allDayOverrides,
calendarId = calendarId,
isAllDay = isAllDay,
)
}
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
@@ -288,39 +241,18 @@ class EventEditViewModel @Inject constructor(
}
/**
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
* app/widget creating an event, e.g. Google Maps' "add to calendar";
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
* calendar still resolves to the last-used/first-writable one.
*
* Reminders are handled per [source], because the two paths mean different
* things by "no reminders":
* - [ImportSource.Insert] carries no reminder semantics, so the settings
* default is applied automatically like an in-app new event (a form that
* somehow did carry reminders keeps them, frozen).
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
* settings default is configured and differs, [importReminderPrompt] offers
* to swap it in rather than silently deciding for the user.
*
* No-op when a form is already open, so the prefill survives configuration
* changes.
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
* the create form" path). [form] already carries the file's fields; its
* [EventForm.calendarId] is null so the calendar still resolves to the
* last-used/first-writable one, and reminders are frozen as touched so the
* settings default never overwrites what the file specified. No-op when a
* form is already open, so the prefill survives configuration changes.
*/
fun openImported(form: EventForm, source: ImportSource) {
fun openImported(form: EventForm) {
if (_form.value != null || _editTarget.value != null) return
_remindersTouched.value = true
_revealed.value = form.populatedFields()
_form.value = form
when (source) {
ImportSource.Insert ->
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
ImportSource.File -> {
// Respect the file's own reminders; never silently overwrite them.
_remindersTouched.value = true
maybePromptImportedReminderDefault(form)
}
}
}
/**
@@ -334,12 +266,26 @@ class EventEditViewModel @Inject constructor(
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch {
val defaults = reminderDefaults()
val defaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
allDayOverrides = defaults.allDayOverrides,
calendarId = targetId,
isAllDay = form.isAllDay,
)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
@@ -349,60 +295,10 @@ class EventEditViewModel @Inject constructor(
}
}
/** Snapshot the four settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
/**
* A `.ics` import respects the file's reminders, but an event opened from a
* file often has none while the user still expects their configured default.
* Rather than silently deciding, raise a one-time offer to swap in the
* settings default — but only when there's a real choice: a default is
* configured and it isn't already exactly what the file carried.
*/
private fun maybePromptImportedReminderDefault(form: EventForm) {
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size,
defaultReminders = default,
)
}
}
/** Accept the import prompt: replace the file's reminders with the default. */
fun applyImportedReminderDefault() {
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
update { it.copy(reminders = prompt.defaultReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
/** Decline the import prompt: keep the file's own reminders untouched. */
fun dismissImportedReminderPrompt() {
_importReminderPrompt.value = null
}
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. An external
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
* form loads the event's real times instead of the epoch (mirrors the
* detail screen's #48 fallback). No-op while a form is open, so user edits
* survive configuration changes.
* tapped occurrence's own times, like on the detail screen. No-op while a
* form is open, so user edits survive configuration changes.
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
@@ -416,12 +312,8 @@ class EventEditViewModel @Inject constructor(
return@launch
}
val zone = TimeZone.currentSystemDefault()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
@@ -437,7 +329,6 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
_importReminderPrompt.value = null
}
/** Unfold one optional field, picked in the "more fields" dialog. */

View File

@@ -310,7 +310,7 @@
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string>
@@ -474,4 +474,20 @@
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_week_numbers">Kalenderwochen</string>
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
<string name="calendars_export_title">Kalender exportieren</string>
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
<string name="calendars_export_action">Exportieren</string>
<string name="calendars_restore_header">Wiederherstellen</string>
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
<string name="import_done_added_label">Hinzugefügt</string>
<string name="import_done_skipped_label">Duplikate</string>
<string name="import_button">Importieren</string>
<plurals name="import_title_count">
<item quantity="one">%d Ereignis wird importiert</item>
<item quantity="other">%d Ereignisse werden importiert</item>
</plurals>
</resources>

View File

@@ -16,17 +16,17 @@
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
<string name="permission_retry_button">Riprova</string>
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
@@ -38,7 +38,7 @@
<string name="month_action_settings">Impostazioni</string>
<string name="month_a11y_today_prefix">Oggi</string>
<string name="week_today_action">Questa settimana</string>
<string name="week_number_label">Wk</string>
<string name="week_number_label">Sett.</string>
<string name="day_today_action">Oggi</string>
<string name="event_detail_back">Indietro</string>
<string name="event_detail_edit">Modifica</string>
@@ -73,14 +73,14 @@
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
<string name="event_edit_attendees">Partecipanti</string>
<string name="event_edit_add_guest">Aggiungi partecipante</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante tramite indirizzo email…</string>
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
<string name="event_edit_attendee_required">Obbligatorio</string>
<string name="event_edit_attendee_optional">Facoltativo</string>
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
<string name="event_edit_reminder_custom">Personalizza</string>
<string name="reminder_unit_minutes">minuti</string>
<string name="reminder_unit_hours">ore</string>
@@ -89,7 +89,7 @@
<string name="event_edit_availability">Disponibilità</string>
<string name="event_edit_visibility">Visibilità</string>
<string name="event_edit_color">Colore</string>
<string name="event_edit_color_default">Colore calendario</string>
<string name="event_edit_color_default">Colore del calendario</string>
<string name="event_edit_color_custom">Colore personalizzato</string>
<string name="event_edit_color_reset">Reset</string>
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
@@ -99,13 +99,13 @@
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
<string name="event_edit_conflict_discard">Annulla le modifiche</string>
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
<string name="event_edit_gone_title">Evento eliminato</string>
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
<string name="event_edit_recurrence_none">Non si ripete</string>
<string name="event_edit_recurrence_custom">Personalizza</string>
<string name="event_edit_recurrence_every">La serie</string>
<string name="event_edit_recurrence_every">Ogni</string>
<string name="recurrence_unit_days">giorni</string>
<string name="recurrence_unit_weeks">settimane</string>
<string name="recurrence_unit_months">mesi</string>
@@ -113,7 +113,7 @@
<string name="event_edit_recurrence_ends">Fine</string>
<string name="event_edit_recurrence_end_never">Mai</string>
<string name="event_edit_recurrence_end_until">Fino alla data</string>
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
<string name="event_edit_recurrence_times">occorrenze</string>
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
<string name="event_availability_busy">Occupato</string>
@@ -125,7 +125,7 @@
<string name="event_detail_location">Luogo</string>
<string name="event_detail_description">Descrizione</string>
<string name="event_detail_attendees">Partecipanti</string>
<string name="recurrence_daily">Ogni giorni</string>
<string name="recurrence_daily">Ogni giorno</string>
<string name="recurrence_weekly">Ogni settimana</string>
<string name="recurrence_monthly">Ogni mese</string>
<string name="recurrence_yearly">Ogni anno</string>
@@ -204,7 +204,7 @@
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
<string name="reminder_onboarding_skip_button">Non ora</string>
<string name="reminder_action_snooze">Posponi</string>
<string name="reminder_action_snooze">Posticipa</string>
<string name="reminder_action_dismiss">Ignora</string>
<string name="view_month">Mese</string>
<string name="view_week">Settimana</string>
@@ -418,16 +418,16 @@
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
<string name="settings_font_headings">Carattere dell\'intestazione</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Carattere personalizzato</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
@@ -463,4 +463,7 @@
<string name="special_dates_calendar_custom">Date speciali</string>
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -117,16 +117,6 @@
<string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">This event was imported with %1$d reminder.</item>
<item quantity="other">This event was imported with %1$d reminders.</item>
</plurals>
<string name="import_reminder_prompt_apply">Apply default</string>
<string name="import_reminder_prompt_keep">Keep as-is</string>
<!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string>

View File

@@ -220,46 +220,6 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
// --- buildOccurrenceCancelValues ("delete only this event") ---
@Test
fun `occurrence cancel anchors a single instance and cancels only it`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P3600S",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
// DTSTART + DURATION make the provider derive a single instance and drop
// the inherited RRULE, so only this occurrence is cancelled — not the
// whole series (#47). DTEND is never sent (the provider rejects it).
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
}
// --- per-event colour ---
@Test