Compare commits
32 Commits
v2.13.0
...
e967007bdc
| Author | SHA1 | Date | |
|---|---|---|---|
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
||||
|
||||
# KSP
|
||||
.ksp/
|
||||
|
||||
# Claude Code
|
||||
/CLAUDE.md
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.13.1] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Create events from other apps and widgets. Calendula now registers the
|
||||
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||
hand off to Calendula to create a new event. It opens the new-event form
|
||||
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||
description, recurrence), and picks your last-used or first writable calendar.
|
||||
Thanks to @dschuermann for the suggestion ([#30]).
|
||||
|
||||
### Fixed
|
||||
- Some recurring events could not be opened. Events in a series that started
|
||||
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||
— showed "Something went wrong" instead of opening, because their stored start
|
||||
time is a negative value that was wrongly treated as invalid. They now open
|
||||
normally and appear in search again. A related case (an event whose stored end
|
||||
precedes its start) is now kept and openable instead of failing the same way.
|
||||
Thanks to @dschuermann for the report ([#34]).
|
||||
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||
24-hour time, the clock dial for choosing an event's start and end time still
|
||||
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||
@abrossimow for the report ([#27]).
|
||||
|
||||
## [2.13.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
@@ -809,4 +834,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21300
|
||||
versionName = "2.13.0"
|
||||
versionCode = 21301
|
||||
versionName = "2.13.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,17 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</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). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -42,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(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.
|
||||
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() {
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
if (action != Intent.ACTION_INSERT) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
|
||||
@@ -71,9 +71,11 @@ interface CalendarDataSource {
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]).
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
*/
|
||||
fun exportableEvents(): List<IcsEvent>
|
||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -611,12 +613,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(): List<IcsEvent> {
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Map id → display name for the
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
val names = calendars()
|
||||
.filter { it.isLocal && it.canModifyContents }
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ interface CalendarRepository {
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
*/
|
||||
suspend fun exportEvents(): List<IcsEvent>
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
|
||||
@@ -117,7 +117,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
|
||||
@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
): EventDetail? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||
// Dropping negatives made every occurrence of such a series un-openable
|
||||
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||
Log.w(TAG, "Dropping event with missing dtstart")
|
||||
return null
|
||||
}
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||
// but dropping the row would make the event un-openable — the same trap as
|
||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
|
||||
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||
*/
|
||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||
// long-running birthdays/anniversaries still surface in search.
|
||||
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||
if (dtStart < 0L) return null
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||
* calendar this way to create a new event, passing the fields as
|
||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||
* `.ics` single-event and plain new-event paths.
|
||||
*
|
||||
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||
*/
|
||||
fun buildInsertEventForm(
|
||||
beginMillis: Long?,
|
||||
endMillis: Long?,
|
||||
isAllDay: Boolean,
|
||||
title: String?,
|
||||
description: String?,
|
||||
location: String?,
|
||||
rrule: String?,
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// All-day provider times are UTC midnights with an exclusive end; show
|
||||
// the last covered day and keep placeholder wall-clock times in case the
|
||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||
val startDate = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?: now.toLocalDateTime(zone).date
|
||||
val endDate = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
val startTime = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?: nextFullHour(now, zone)
|
||||
val endTime = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?.takeIf { it >= startTime }
|
||||
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
startTime to endTime
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
title = title.orEmpty(),
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||
// some callers include.
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||
val hourMillis = 3_600_000L
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
}
|
||||
@@ -72,6 +72,8 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -170,12 +172,28 @@ 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) }
|
||||
// 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
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||
// issue #30) arrives already prefilled — open it in the same create form the
|
||||
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
@@ -400,7 +418,10 @@ fun CalendarHost(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(onBack = { showCalendars = false })
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onImport = { importUri = it; importForceMany = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
@@ -408,6 +429,7 @@ fun CalendarHost(
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
|
||||
@@ -35,6 +35,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -84,6 +86,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
@@ -45,6 +46,7 @@ import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -96,6 +98,11 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
@@ -112,6 +119,16 @@ import java.time.LocalDate
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||
// mimes depending on the source app (our own export uses text/calendar; others
|
||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||
// than hide valid backups behind an over-tight filter.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||
@@ -122,6 +139,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
@@ -168,6 +186,7 @@ fun CalendarsScreen(
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onImport = onImport,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
@@ -190,7 +209,8 @@ private fun CalendarsList(
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
@@ -218,10 +238,19 @@ private fun CalendarsList(
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into.
|
||||
// to the VM to stream the .ics into. This launcher exports everything
|
||||
// eligible (null); the per-calendar selector owns its own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||
var showExportPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// SAF "open document" picker for restoring events from a .ics file. The
|
||||
// picked Uri is handed up to the host, which runs it through the same import
|
||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
@@ -301,8 +330,10 @@ private fun CalendarsList(
|
||||
}
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
// safety net. Offered only when there is something exportable: the user's
|
||||
// own local calendars (managed special-dates mirrors don't count).
|
||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||
if (exportable.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
@@ -313,7 +344,22 @@ private fun CalendarsList(
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
// With more than one exportable calendar, let the user choose
|
||||
// which to include; a single one exports straight away.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = {
|
||||
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
@@ -408,6 +454,76 @@ private fun CalendarsList(
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||
onExport = onExportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var selected by remember(calendars) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -741,66 +857,6 @@ private fun CalendarGroupMenu(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||
* chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
private fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
@@ -935,9 +991,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents()
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
/**
|
||||
* The app's single "which calendar" selection list, shared by the event editor
|
||||
* and the .ics import screen. Renders the same grouped-card system as the
|
||||
* calendar-manager screen: a category header per source — the device chip for
|
||||
* the app's own calendars, the owning app's launcher icon for each synced
|
||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CalendarPickerGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.toList()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = account,
|
||||
leading = { SourceLogo(cals.first().accountType) },
|
||||
calendars = cals,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = Position.Top,
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [android.content.pm.PackageManager] rather
|
||||
* than bundling brand logos — always accurate, nothing to license. Falls back to
|
||||
* a neutral cloud chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
||||
onConfirm: (LocalTime) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Honour the app's own time-format preference (the value every time label
|
||||
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||
// still showed an AM/PM dial (issue #27).
|
||||
val state = rememberTimePickerState(
|
||||
initialHour = initial.hour,
|
||||
initialMinute = initial.minute,
|
||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||
is24Hour = LocalUse24HourFormat.current,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
||||
text = { TimePicker(state = state) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||
*
|
||||
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||
* the *app's* context locale — and this app applies a per-app language
|
||||
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||
* (Resources.getSystem), not the app's.
|
||||
*/
|
||||
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||
"24" -> true
|
||||
"12" -> false
|
||||
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||
else -> {
|
||||
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
@@ -1975,50 +1976,14 @@ private fun CalendarPicker(
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Group by owning account (name, else type, else the calendar's own name),
|
||||
// preserving the provider's order within and across groups — the same
|
||||
// grouping [groupByAccount] applies for the drawer filter.
|
||||
val groups = remember(calendars) {
|
||||
calendars
|
||||
.groupBy {
|
||||
it.accountName.takeIf(String::isNotBlank)
|
||||
?: it.accountType.takeIf(String::isNotBlank)
|
||||
?: it.displayName
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
groups.forEach { (account, cals) ->
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
CalendarPickerGroups(
|
||||
calendars = calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -30,6 +43,12 @@ 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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
|
||||
/**
|
||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||
* Empty/failed files show a short message and close.
|
||||
* Empty/failed files show a short message and close. [forceMany] keeps a
|
||||
* single-event file on the bulk path — used by the in-app restore, whose intent
|
||||
* is "restore a backup" rather than "add this one event".
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -53,9 +74,10 @@ fun ImportScreen(
|
||||
uri: Uri,
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
forceMany: Boolean = false,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
@@ -63,18 +85,55 @@ fun ImportScreen(
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
// read it without the user scrolling to a bottom button. Defaults to the
|
||||
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||
// group leads) — so the pre-selection lines up with the top of the list;
|
||||
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||
val many = state as? ImportUiState.Many
|
||||
val defaultTarget = many?.calendars?.let { cals ->
|
||||
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||
}
|
||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.import_title)) },
|
||||
title = {
|
||||
Text(
|
||||
if (many != null) {
|
||||
pluralStringResource(
|
||||
R.plurals.import_title_count,
|
||||
many.events.size,
|
||||
many.events.size,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.import_title)
|
||||
},
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Only meaningful in the multi-event picker with a writable
|
||||
// target; every other state has nothing to confirm here.
|
||||
if (many != null && many.calendars.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = { selected?.let(viewModel::import) },
|
||||
enabled = selected != null,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.import_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
@@ -90,7 +149,7 @@ fun ImportScreen(
|
||||
|
||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||
}
|
||||
}
|
||||
@@ -98,84 +157,160 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||
// No writable calendar to import into — tell the user honestly.
|
||||
if (state.calendars.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||
return
|
||||
}
|
||||
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
CalendarPickerGroups(
|
||||
calendars = state.calendars,
|
||||
selectedId = selected,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.import_target_header),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
state.calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
label = calendar.displayName,
|
||||
onClick = { selected = calendar.id },
|
||||
selected = calendar.id == selected,
|
||||
icon = null,
|
||||
)
|
||||
}
|
||||
state.warnings.forEach { WarningText(it) }
|
||||
Button(
|
||||
onClick = { onImport(selected) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||
if (state.warnings.isNotEmpty()) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
state.warnings.forEach { WarningText(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
// A little expressive pop on the success badge — springs in on first show.
|
||||
val badgeScale = remember { Animatable(0.7f) }
|
||||
LaunchedEffect(Unit) {
|
||||
badgeScale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier
|
||||
.size(112.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = badgeScale.value
|
||||
scaleY = badgeScale.value
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.import_done_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_imported,
|
||||
state.summary.imported,
|
||||
state.summary.imported,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_skipped,
|
||||
state.summary.skippedDuplicate,
|
||||
state.summary.skippedDuplicate,
|
||||
),
|
||||
stringResource(R.string.import_done_dedup_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ImportStatCard(
|
||||
count = state.summary.imported,
|
||||
label = stringResource(R.string.import_done_added_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_imported,
|
||||
state.summary.imported,
|
||||
state.summary.imported,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
ImportStatCard(
|
||||
count = state.summary.skippedDuplicate,
|
||||
label = stringResource(R.string.import_done_skipped_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_skipped,
|
||||
state.summary.skippedDuplicate,
|
||||
state.summary.skippedDuplicate,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.import_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||
@Composable
|
||||
private fun RowScope.ImportStatCard(
|
||||
count: Int,
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
container: Color,
|
||||
onContainer: Color,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clearAndSetSemantics { this.contentDescription = contentDescription },
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = container,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
count.toString(),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = onContainer,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WarningText(warning: IcsParseWarning) {
|
||||
val text = when (warning) {
|
||||
|
||||
@@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor(
|
||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||
private var started = false
|
||||
|
||||
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||
fun load(uri: Uri) {
|
||||
/**
|
||||
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||
* through the bulk picker + summary rather than the prefilled create form —
|
||||
* a restore is "bring back a backup", not "add this one event".
|
||||
*/
|
||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||
if (started) return
|
||||
started = true
|
||||
viewModelScope.launch {
|
||||
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
|
||||
_state.value = when {
|
||||
parsed == null -> ImportUiState.Failed
|
||||
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||
parsed.events.size == 1 -> ImportUiState.Single(
|
||||
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
|
||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> {
|
||||
// A disabled calendar is removed from the app, so it can't be
|
||||
// an import target — exclude it alongside the read-only ones.
|
||||
// Managed special-dates calendars are contact-derived and
|
||||
// editor-locked, so they're not a valid destination either.
|
||||
val disabled = prefs.disabledCalendarIds.first()
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.canModifyContents && it.id !in disabled },
|
||||
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</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_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</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>
|
||||
@@ -309,7 +309,7 @@
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||
<string name="settings_appearance_subtitle">Design, Standartansicht, 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>
|
||||
@@ -387,4 +387,27 @@
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">Woche</item>
|
||||
<item quantity="other">Wochen</item>
|
||||
</plurals>
|
||||
<string name="settings_default_view">Standartansicht</string>
|
||||
<string name="settings_font_headings">Überschriften Schriftart</string>
|
||||
<string name="settings_font_system">Systemstandard</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Datei auswählen…</string>
|
||||
<string name="settings_font_import_failed">Die Datei konnte nicht als Schriftart gelesen werden</string>
|
||||
<string name="settings_dim_completed">Vergangene Ereignisse abdunkeln</string>
|
||||
<string name="settings_dim_completed_summary">Vergangene Termine in der Monats- und Wochenansicht abdunkeln</string>
|
||||
<string name="settings_past_events">Vergangene Termine</string>
|
||||
<string name="settings_past_events_show">Zeigen</string>
|
||||
<string name="settings_past_events_dim">Abdunkeln</string>
|
||||
<string name="settings_past_events_hide">Ausblenden</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
|
||||
<string name="state_failure_provider">No se pudo leer el calendario.</string>
|
||||
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
|
||||
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
|
||||
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá para comenzar a funcionar. Y nada de información sale de tu dispositivo.</string>
|
||||
<string name="permission_request_button">Conceder el acceso al calendario</string>
|
||||
<string name="permission_denied_title">Acceso a calendario denegado</string>
|
||||
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
|
||||
@@ -287,7 +287,7 @@
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Nueva formulario de evento</string>
|
||||
<string name="settings_section_event_form">Formulario de nuevo evento</string>
|
||||
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
|
||||
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
|
||||
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
|
||||
@@ -418,4 +418,52 @@
|
||||
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||
<string name="recurrence_with_until">%1$s hasta %2$s</string>
|
||||
<string name="recurrence_on_days">%1$s los %2$s</string>
|
||||
<string name="event_edit_managed_hint">Administrado por “%1$s” — el titulo, fecha y recurrencia se mantienen sincronizados con la inflación de tus contactos. Puedes editar los recordatorios, ubicacion y notas.</string>
|
||||
<string name="settings_font_headings">Fuente de los títulos</string>
|
||||
<string name="settings_font_body">Fuente del cuerpo del texto</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Escoger desde archivo…</string>
|
||||
<string name="settings_font_custom_selected">Fuente personalizada</string>
|
||||
<string name="settings_font_import_failed">No se pude leer el archivo como fuente</string>
|
||||
<string name="settings_section_views">Vistas</string>
|
||||
<string name="settings_quick_switch_header">Botón de cambio rápido</string>
|
||||
<string name="settings_quick_switch_hint">Escoge que vistas muestra el botón superior derecho y arrastralas para reordenalas. Las vistas deshabitadas permanecen accesibles desde el menú de navegación.</string>
|
||||
<string name="settings_drawer_order_header">Menú de navegación</string>
|
||||
<string name="settings_drawer_order_hint">Arrastra para cambiar el orden de las vistas que aparecen en el menú de navegación.</string>
|
||||
<string name="reorder_drag_handle">Arrastra para reordenar</string>
|
||||
<string name="settings_views_subtitle">Orden del botón de cambio rápido y menú</string>
|
||||
<string name="settings_special_dates_subtitle">Aniversarios y cumpleaños de los contactos</string>
|
||||
<string name="settings_section_special_dates">Fechas especiales de contactos</string>
|
||||
<string name="settings_special_dates_enable">Mostrar fechas de contactos</string>
|
||||
<string name="settings_special_dates_enable_hint">Refleja los cumpleaños y otras fechas de tus contactos en calendarios locales. Solo puede leer los contactos de este dispositivo — nada es compartido ni editado.</string>
|
||||
<string name="settings_special_dates_type_birthday">Cumpleaños</string>
|
||||
<string name="settings_special_dates_type_anniversary">Aniversarios</string>
|
||||
<string name="settings_special_dates_type_custom">Otras fechas</string>
|
||||
<string name="settings_special_dates_template">Formato de titulo</string>
|
||||
<string name="settings_special_dates_template_hint">Usa {name} para el contacto y {year} para el año (el año de nacimiento u origen del aniversario; oculto cuando es desconocido).</string>
|
||||
<string name="settings_special_dates_reminders">Recordatorios</string>
|
||||
<string name="settings_special_dates_show_year">Mostrar año</string>
|
||||
<string name="settings_special_dates_show_year_hint">Incluye {year} en títulos cuando se conoce el año</string>
|
||||
<string name="settings_special_dates_sync_now">Sincronizar ahora</string>
|
||||
<string name="settings_special_dates_never_synced">No se ha sincronizado todavía</string>
|
||||
<string name="settings_special_dates_last_synced">Sincronizado por ultima vez %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Cambia el color y visibilidad de cada calendario en ajustes de calendarios.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Establecido en Fechas especiales de contactos</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ya no puede leer tus contactos, por lo que estos calendarios ya no se estan actualizando.</string>
|
||||
<string name="settings_special_dates_disable_title">¿Desactivar las fechas de contactos?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Esto eliminara los calendarios de fechas de contactos y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
|
||||
<string name="settings_special_dates_paused_title">Pausado</string>
|
||||
<string name="settings_special_dates_disable_type_message">Esto eliminara el calendario “%1$s” y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Desactivar</string>
|
||||
<string name="special_dates_calendar_birthday">Cumpleaños</string>
|
||||
<string name="special_dates_calendar_anniversary">Aniversarios</string>
|
||||
<string name="special_dates_calendar_custom">Fechas especiales</string>
|
||||
<string name="special_dates_default_title_birthday">Cumpleaños de {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Aniversario de {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="settings_special_dates_grant">Permitir acceso</string>
|
||||
<string name="dialog_save">Guardar</string>
|
||||
<string name="settings_font_system">Igual que el sistema</string>
|
||||
</resources>
|
||||
|
||||
@@ -418,4 +418,42 @@
|
||||
<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="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_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_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>
|
||||
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
|
||||
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
||||
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
||||
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
||||
<string name="settings_special_dates_enable_hint">Riporta compleanni ed altre date dei tuoi contatti nei calendari locali. Legge i contatti solo su questo dispositivo - niente viene caricato in rete ed i tuoi contatti non vengono mai modificati.</string>
|
||||
<string name="settings_special_dates_type_birthday">Compleanni</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversari</string>
|
||||
<string name="settings_special_dates_type_custom">Altre date</string>
|
||||
<string name="settings_special_dates_template">Formato del titolo</string>
|
||||
<string name="settings_special_dates_template_hint">Usa {name} per il contatto e {year} per l\'anno (l\'anno di nascita, o l\'anno di inizio di un anniversario; nascosto se sconosciuto).</string>
|
||||
<string name="settings_special_dates_reminders">Promemoria</string>
|
||||
<string name="settings_special_dates_show_year">Mostra anno</string>
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} nei titoli quando è noto</string>
|
||||
<string name="settings_special_dates_sync_now">Sincronizza adesso</string>
|
||||
<string name="settings_special_dates_never_synced">Ancora non sincronizzato</string>
|
||||
<string name="settings_special_dates_last_synced">%1$s è un tempo relativo, per esempio \"5 minuti fa\"</string>
|
||||
<string name="settings_special_dates_calendar_hint">Imposta colore e visibilità di ogni calendario nelle impostazioni dei calendari.</string>
|
||||
<string name="settings_special_dates_paused_title">In pausa</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula non può più leggere i tuoi contatti, quindi questi calendari non vengono aggiornati.</string>
|
||||
<string name="settings_special_dates_grant">Concedi accesso</string>
|
||||
<string name="settings_special_dates_disable_title">Disattivare date dei contatti?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Questo cancella i calendari delle date dei contatti ed i loro eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Spegni</string>
|
||||
<string name="dialog_save">Salva</string>
|
||||
</resources>
|
||||
|
||||
122
app/src/main/res/values-zh-rCN/strings.xml
Normal file
122
app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">一个现代化的日历应用</string>
|
||||
<string name="state_loading">加载中…</string>
|
||||
<string name="state_retry">推迟</string>
|
||||
<string name="state_failure_unknown">错误!</string>
|
||||
<string name="state_failure_permission">需要日历权限</string>
|
||||
<string name="state_failure_permission_action">授予访问权限</string>
|
||||
<string name="state_failure_no_calendars">无已设置的日历</string>
|
||||
<string name="state_failure_no_calendars_action">打开系统日历设置</string>
|
||||
<string name="state_failure_provider">无法读取日历</string>
|
||||
<string name="permission_rationale_title">优雅地查看你的所有活动</string>
|
||||
<string name="permission_rationale_body">Calendula需要获取权限来访问您的日历来展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露。</string>
|
||||
<string name="permission_request_button">授予日历权限</string>
|
||||
<string name="permission_denied_title">日历权限不可用</string>
|
||||
<string name="permission_denied_body">Calendula 无法显示没有日历访问权限的事件。你可以在系统设置里再次授予它。</string>
|
||||
<string name="permission_open_settings_button">打开系统设置</string>
|
||||
<string name="permission_retry_button">重试</string>
|
||||
<string name="permission_benefit_private_title">保留在设备</string>
|
||||
<string name="permission_benefit_private_body">您的日历数据将会在本地读取,而不会被上传。</string>
|
||||
<string name="permission_benefit_sync_title">所有日历</string>
|
||||
<string name="permission_benefit_sync_body">Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来。</string>
|
||||
<string name="permission_benefit_privacy_title">无追踪器</string>
|
||||
<string name="permission_benefit_privacy_body">零遥测,零分析,没有广告。</string>
|
||||
<string name="permission_privacy_footnote">数据仅保留在本地设备 ·无网络权限</string>
|
||||
<string name="month_prev">上个月</string>
|
||||
<string name="month_next">下个月</string>
|
||||
<string name="month_today_action">今天</string>
|
||||
<string name="month_more_actions">更多选项</string>
|
||||
<string name="month_open_menu">打开菜单</string>
|
||||
<string name="month_action_settings">设置</string>
|
||||
<string name="month_a11y_today_prefix">今天</string>
|
||||
<string name="week_today_action">本周</string>
|
||||
<string name="day_today_action">今天</string>
|
||||
<string name="event_detail_back">返回</string>
|
||||
<string name="event_detail_edit">编辑</string>
|
||||
<string name="event_detail_delete">删除</string>
|
||||
<string name="event_detail_share">分享</string>
|
||||
<string name="event_share_chooser_title">分享活动</string>
|
||||
<string name="event_share_failed">无法分享该活动。</string>
|
||||
<string name="event_delete_title">删除活动?</string>
|
||||
<string name="event_delete_body">该活动将从你的日历以及所有同步设备中移除。</string>
|
||||
<string name="event_delete_recurring_title">删除重复活动</string>
|
||||
<string name="event_delete_option_occurrence">仅该活动</string>
|
||||
<string name="event_delete_option_following">当前及接下来的所有活动</string>
|
||||
<string name="event_delete_option_series">系列中的所有活动</string>
|
||||
<string name="event_edit_recurring_title">编辑重复活动</string>
|
||||
<string name="event_delete_failed">无法删除该活动</string>
|
||||
<string name="event_delete_write_denied">Calendula需要权限读取或删除活动</string>
|
||||
<string name="dialog_cancel">取消</string>
|
||||
<string name="dialog_ok">确认</string>
|
||||
<string name="event_edit_new_title">新活动</string>
|
||||
<string name="event_edit_close">关闭</string>
|
||||
<string name="event_edit_save">保存</string>
|
||||
<string name="event_edit_title_hint">增加标题</string>
|
||||
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。</string>
|
||||
<string name="event_edit_starts">开始</string>
|
||||
<string name="event_edit_ends">结束</string>
|
||||
<string name="event_edit_error_end_before_start">报错:结束时间早于起始时间</string>
|
||||
<string name="event_edit_error_no_calendar">无可写入的日历</string>
|
||||
<string name="event_edit_save_failed">无法保存该活动</string>
|
||||
<string name="event_edit_write_denied">Calendula需要写入权限来创建活动</string>
|
||||
<string name="event_edit_more_fields">其他领域</string>
|
||||
<string name="event_edit_add">新增</string>
|
||||
<string name="event_edit_add_reminder">新建提醒</string>
|
||||
<string name="event_edit_remove_reminder">移除提醒</string>
|
||||
<string name="event_edit_attendees">访问者</string>
|
||||
<string name="event_edit_add_guest">新增访问者</string>
|
||||
<string name="event_edit_add_guest_hint">通过 Email 新增一个访问者…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">从联系人中添加访问者</string>
|
||||
<string name="event_edit_location_from_contacts">从联系人中添加地址</string>
|
||||
<string name="event_edit_remove_guest">移除访问者</string>
|
||||
<string name="event_edit_attendee_required">必填</string>
|
||||
<string name="event_edit_attendee_optional">可选项</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请函。你的日历账户同步时可能会给访客发邮件。</string>
|
||||
<string name="event_edit_attendees_note_local">保存在本设备,其他访客不会被提醒。</string>
|
||||
<string name="event_edit_reminder_custom">自定义</string>
|
||||
<string name="reminder_unit_minutes">分钟</string>
|
||||
<string name="reminder_unit_hours">小时</string>
|
||||
<string name="reminder_unit_days">天</string>
|
||||
<string name="reminder_unit_weeks">周</string>
|
||||
<string name="event_edit_availability">空闲</string>
|
||||
<string name="event_edit_visibility">隐私设置</string>
|
||||
<string name="event_edit_color">色彩</string>
|
||||
<string name="event_edit_color_default">日历颜色</string>
|
||||
<string name="event_edit_color_custom">自定义颜色</string>
|
||||
<string name="event_edit_color_reset">重置</string>
|
||||
<string name="event_edit_color_unsupported">该日历不可用</string>
|
||||
<string name="event_edit_color_unsupported_hint">此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。</string>
|
||||
<string name="event_edit_color_sync_warning">该日历在下一次同步时可能会删除或覆盖该颜色。</string>
|
||||
<string name="event_edit_conflict_title">其他设备的活动已更改</string>
|
||||
<string name="event_edit_conflict_body">您在编辑时,该事件已被同步或其他应用修改。您的修改要怎么处理?</string>
|
||||
<string name="event_edit_conflict_overwrite">保存我的更改</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">仅您编辑的字段会覆盖外部更改</string>
|
||||
<string name="event_edit_conflict_discard">丢弃我的更改</string>
|
||||
<string name="event_edit_conflict_discard_hint">该活动保持现状</string>
|
||||
<string name="event_edit_gone_title">活动被删除</string>
|
||||
<string name="event_edit_gone_body">这个事件在您编辑期间已被删除了(比如在另一台设备上)。您的修改现在无法保存。</string>
|
||||
<string name="event_edit_recurrence_none">确认无需重复</string>
|
||||
<string name="event_edit_recurrence_custom">自定义</string>
|
||||
<string name="event_edit_recurrence_every">每</string>
|
||||
<string name="recurrence_unit_days">天</string>
|
||||
<string name="recurrence_unit_weeks">周</string>
|
||||
<string name="recurrence_unit_months">月</string>
|
||||
<string name="recurrence_unit_years">年</string>
|
||||
<string name="event_edit_recurrence_ends">结束时间</string>
|
||||
<string name="event_edit_recurrence_end_never">从不</string>
|
||||
<string name="event_edit_recurrence_end_until">于指定日期</string>
|
||||
<string name="event_edit_recurrence_end_count">重复几次后</string>
|
||||
<string name="event_edit_recurrence_times">次</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">报错:结束日期早于开始日期</string>
|
||||
<string name="event_availability_busy">占用中</string>
|
||||
<string name="event_access_default">默认</string>
|
||||
<string name="event_access_public">公开</string>
|
||||
</resources>
|
||||
@@ -428,6 +428,11 @@
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_export_title">Export calendars</string>
|
||||
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
|
||||
<string name="calendars_export_action">Export</string>
|
||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
@@ -460,11 +465,19 @@
|
||||
<string name="import_failed">Couldn\'t read this file.</string>
|
||||
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
|
||||
<string name="import_done_title">Import complete</string>
|
||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||
<string name="import_done_added_label">Added</string>
|
||||
<string name="import_done_skipped_label">Duplicates</string>
|
||||
<string name="import_close">Close</string>
|
||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||
<string name="import_button">Import</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importing %d event</item>
|
||||
<item quantity="other">Importing %d events</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d event in this file.</item>
|
||||
<item quantity="other">%d events in this file.</item>
|
||||
|
||||
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
|
||||
eventColor: Any? = null,
|
||||
eventColorKey: String? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
dtstart: Long = 1_000_000_000L,
|
||||
dtend: Long = 1_000_003_600L,
|
||||
dtstart: Long? = 1_000_000_000L,
|
||||
dtend: Long? = 1_000_003_600L,
|
||||
allDay: Int = 0,
|
||||
location: String? = "Berlin",
|
||||
calendarId: Long = 7L,
|
||||
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dtend before dtstart drops detail`() {
|
||||
fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
|
||||
// A backwards DTEND is malformed, but dropping it would make the event
|
||||
// un-openable (the "Something went wrong" trap, same as issue #34); keep
|
||||
// it as a zero-length event so the user can still open and fix it.
|
||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||
assertThat(detail).isNull()
|
||||
assertThat(detail).isNotNull()
|
||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L)
|
||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||
// A yearly birthday/anniversary anchored before the epoch has a
|
||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||
// no DTEND (they use DURATION), so it stays end == begin.
|
||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
||||
assertThat(detail).isNotNull()
|
||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent dtstart drops detail`() {
|
||||
assertThat(detailReader(dtstart = null).toDetail()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -59,7 +59,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> = exportableEventsResult
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SearchMapperTest {
|
||||
|
||||
private fun searchReader(
|
||||
id: Long = 1L,
|
||||
calendarId: Long = 7L,
|
||||
title: String? = "Birthday",
|
||||
dtstart: Long? = 1_000_000_000L,
|
||||
dtend: Long? = null,
|
||||
duration: String? = "P1D",
|
||||
allDay: Int = 1,
|
||||
eventColor: Any? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
location: String? = null,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
SearchProjection.IDX_ID to id,
|
||||
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||
SearchProjection.IDX_TITLE to title,
|
||||
SearchProjection.IDX_DTSTART to dtstart,
|
||||
SearchProjection.IDX_DTEND to dtend,
|
||||
SearchProjection.IDX_DURATION to duration,
|
||||
SearchProjection.IDX_ALL_DAY to allDay,
|
||||
SearchProjection.IDX_EVENT_COLOR to eventColor,
|
||||
SearchProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||
SearchProjection.IDX_LOCATION to location,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() {
|
||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||
val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult()
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||
assertThat(result.title).isEqualTo("Birthday")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent dtstart drops the search hit`() {
|
||||
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class InsertEventFormTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
// A fixed "now" at 2026-01-15T10:20:00Z; the next full hour is 11:00.
|
||||
private val now = LocalDateTime(2026, 1, 15, 10, 20).toInstant(zone)
|
||||
|
||||
private fun build(
|
||||
beginMillis: Long? = null,
|
||||
endMillis: Long? = null,
|
||||
isAllDay: Boolean = false,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
location: String? = null,
|
||||
rrule: String? = null,
|
||||
): EventForm = buildInsertEventForm(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = isAllDay,
|
||||
title = title,
|
||||
description = description,
|
||||
location = location,
|
||||
rrule = rrule,
|
||||
zone = zone,
|
||||
now = now,
|
||||
)
|
||||
|
||||
private fun millis(dateTime: LocalDateTime): Long = dateTime.toInstant(zone).toEpochMilliseconds()
|
||||
|
||||
@Test
|
||||
fun `timed event maps all provided fields`() {
|
||||
val start = LocalDateTime(2026, 3, 1, 14, 30)
|
||||
val end = LocalDateTime(2026, 3, 1, 15, 45)
|
||||
val form = build(
|
||||
beginMillis = millis(start),
|
||||
endMillis = millis(end),
|
||||
title = "Standup",
|
||||
description = "Daily",
|
||||
location = "Room 1",
|
||||
rrule = "FREQ=DAILY",
|
||||
)
|
||||
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
|
||||
assertThat(form.isAllDay).isFalse()
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(end)
|
||||
assertThat(form.title).isEqualTo("Standup")
|
||||
assertThat(form.description).isEqualTo("Daily")
|
||||
assertThat(form.location).isEqualTo("Room 1")
|
||||
assertThat(form.rrule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing times default to the next full hour and a one-hour duration`() {
|
||||
val form = build(title = "Quick note")
|
||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
||||
assertThat(form.title).isEqualTo("Quick note")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event with only a start gets a one-hour end`() {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val form = build(beginMillis = millis(start))
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end before the start is ignored and falls back to plus one hour`() {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
|
||||
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event uses UTC dates and decrements the exclusive end`() {
|
||||
val startMillis = LocalDate(2026, 1, 1).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val endExclusive = LocalDate(2026, 1, 3).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val form = build(beginMillis = startMillis, endMillis = endExclusive, isAllDay = true, title = "Trip")
|
||||
assertThat(form.isAllDay).isTrue()
|
||||
assertThat(form.start.date).isEqualTo(LocalDate(2026, 1, 1))
|
||||
assertThat(form.end.date).isEqualTo(LocalDate(2026, 1, 2))
|
||||
// Placeholder wall-clock times survive a switch back to a timed event.
|
||||
assertThat(form.start.time).isEqualTo(LocalTime(9, 0))
|
||||
assertThat(form.end.time).isEqualTo(LocalTime(10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event without an end is a single day`() {
|
||||
val startMillis = LocalDate(2026, 6, 10).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val form = build(beginMillis = startMillis, isAllDay = true)
|
||||
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 10))
|
||||
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading RRULE prefix is stripped and a blank rule becomes null`() {
|
||||
assertThat(build(rrule = "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(build(rrule = " ").rrule).isNull()
|
||||
assertThat(build(rrule = null).rrule).isNull()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,204 +0,0 @@
|
||||
# Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Calendula kann Events anlegen, bearbeiten und löschen — direkt über
|
||||
`CalendarContract`-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
|
||||
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
|
||||
ist für sich releasebar und lässt `./gradlew lint test assembleDebug` grün.
|
||||
|
||||
**Architecture:** Writes laufen durch dieselbe Schichtung wie Reads:
|
||||
`ui/` → `CalendarRepository` (Interface) → `CalendarDataSource` →
|
||||
`ContentResolver.insert/update/delete`. Kein neuer Layer, keine Transaktions-
|
||||
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
|
||||
`ContentObserver`-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
|
||||
Domain bleibt pure Kotlin.
|
||||
|
||||
**Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):**
|
||||
|
||||
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt ins Manifest. Das Onboarding
|
||||
fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt
|
||||
nur READ — wer Write ablehnt, nutzt die App weiter read-only.
|
||||
v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim
|
||||
ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"-
|
||||
Behauptung (wäre mit Manifest-Eintrag gelogen).
|
||||
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
|
||||
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
|
||||
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
|
||||
Geburtstags- und andere read-only-Kalender.
|
||||
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
|
||||
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_CANCELED` +
|
||||
`ORIGINAL_INSTANCE_TIME`) vs. "Ganze Serie" (Delete der Events-Row).
|
||||
Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen
|
||||
Werten) folgt erst, wenn das Serien-Edit stabil ist.
|
||||
4. **Kein RRULE-Editor in v1.2:** Create startet ohne Wiederholungs-UI
|
||||
(einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/
|
||||
monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
|
||||
5. **Conflict UX (Spec V2 "event modified externally during edit"):** kein
|
||||
Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen
|
||||
(Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog
|
||||
"Überschreiben / Verwerfen". Mehr ist YAGNI.
|
||||
|
||||
---
|
||||
|
||||
## Slices
|
||||
|
||||
| Slice | Inhalt | Status |
|
||||
|---|---|---|
|
||||
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
|
||||
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
|
||||
|
||||
## v1.1 — Write-Fundament + Delete
|
||||
|
||||
**Build/Manifest:**
|
||||
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
|
||||
|
||||
**Data layer:**
|
||||
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
|
||||
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
|
||||
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
|
||||
Full-Screen-Failure
|
||||
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
|
||||
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
|
||||
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
|
||||
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
|
||||
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
|
||||
|
||||
**UI:**
|
||||
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
|
||||
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
|
||||
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
|
||||
WRITE-Request statt Failure-Screen
|
||||
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
|
||||
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
|
||||
Erfolg → zurück, Fehler → Snackbar
|
||||
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
|
||||
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
|
||||
|
||||
**Tests:**
|
||||
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
|
||||
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
|
||||
- [x] `CalendarMapperTest`: Access-Level-Mapping
|
||||
|
||||
## v1.2 — Create
|
||||
|
||||
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
|
||||
NoCalendar; leerer Titel und Instant-Events erlaubt)
|
||||
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
|
||||
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
|
||||
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
|
||||
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
|
||||
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
|
||||
beschreibbarer; Picker bietet nur beschreibbare Kalender an
|
||||
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
|
||||
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
|
||||
|
||||
## v1.3 — Edit
|
||||
|
||||
**Domain:**
|
||||
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
|
||||
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
|
||||
solange der Picker sie nicht ersetzt
|
||||
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
|
||||
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
|
||||
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
|
||||
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
|
||||
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
|
||||
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
|
||||
null Vorkommen — Event würde unsichtbar)
|
||||
|
||||
**Data layer:**
|
||||
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)` —
|
||||
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
|
||||
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
|
||||
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
|
||||
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
|
||||
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
|
||||
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
|
||||
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
|
||||
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
|
||||
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
|
||||
|
||||
**UI:**
|
||||
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
|
||||
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
|
||||
Werten werden unabhängig vom Settings-Default eingeblendet
|
||||
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
|
||||
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
|
||||
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
|
||||
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
|
||||
OptionCard-Stil)
|
||||
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
|
||||
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
|
||||
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
|
||||
getappte Occurrence existiert danach evtl. nicht mehr)
|
||||
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
|
||||
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
|
||||
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
|
||||
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
|
||||
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
|
||||
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
|
||||
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
|
||||
Formularwerte, leere Optionals als explizite NULLs weil der Provider
|
||||
die Serien-Row klont), Reminder werden gegen die tatsächlichen
|
||||
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
|
||||
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
|
||||
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
|
||||
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
|
||||
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
|
||||
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
|
||||
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
|
||||
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
|
||||
löschen
|
||||
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
|
||||
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
|
||||
adb-Probe verifiziert): der Provider regeneriert die Instances eines
|
||||
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
|
||||
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
|
||||
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
|
||||
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
|
||||
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
|
||||
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
|
||||
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
|
||||
statt Occurrence−1s, und der Recurrence-Picker rendert UNTIL als
|
||||
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
|
||||
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
|
||||
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
|
||||
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
|
||||
- [x] Strings DE+EN
|
||||
|
||||
**Tests:**
|
||||
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
|
||||
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
|
||||
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
|
||||
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
|
||||
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
|
||||
- [x] `EventDetailMapperTest`: roher Titel
|
||||
|
||||
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
||||
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
||||
|
||||
## v2.0 — Abschluss (Scope-Recut 2026-06-11, nach v1.4)
|
||||
|
||||
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
|
||||
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
|
||||
Kalender, optionale Felder versteckt); der Sheet spart nur einen
|
||||
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
|
||||
Praxis-Feedback wieder aufnehmen
|
||||
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
|
||||
ausgeliefert (vorgezogen)
|
||||
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
|
||||
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
|
||||
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
|
||||
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
|
||||
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
|
||||
schreibt weiterhin nur dirty Felder
|
||||
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
|
||||
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
|
||||
aktualisiert (DE+EN)
|
||||
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
|
||||
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
|
||||
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
|
||||
damit abgeschlossen)
|
||||
@@ -1,119 +0,0 @@
|
||||
# Calendula - Plan 04: Reminder Notifications (v1.4)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
|
||||
Der Provider plant die Alarme und broadcastet
|
||||
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
|
||||
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
|
||||
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
|
||||
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||
On-Device-Review.
|
||||
|
||||
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
|
||||
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
|
||||
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
|
||||
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
|
||||
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
|
||||
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
|
||||
|
||||
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
|
||||
|
||||
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
|
||||
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
|
||||
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
|
||||
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
|
||||
`METHOD_ALERT`.
|
||||
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
|
||||
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
|
||||
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
|
||||
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
|
||||
"fällig & noch SCHEDULED".
|
||||
3. Etar postet aus dem Zustand `SCHEDULED ∪ FIRED` und verwaltet Dismiss über
|
||||
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
|
||||
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
|
||||
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
|
||||
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
|
||||
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
|
||||
`setOnlyAlertOnce`).
|
||||
|
||||
**Leitentscheidungen:**
|
||||
|
||||
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
|
||||
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
|
||||
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
|
||||
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
|
||||
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
|
||||
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
|
||||
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
|
||||
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
|
||||
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
|
||||
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
|
||||
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
|
||||
v1.0–v1.3-Upgrader, die das Feature so entdecken.
|
||||
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
|
||||
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
|
||||
wenn sie fehlt.
|
||||
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
|
||||
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
|
||||
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
|
||||
der Detail-Overlay selbst).
|
||||
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
|
||||
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
|
||||
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
|
||||
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
|
||||
Vollbild-Alarm.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
**Manifest / Resourcen:**
|
||||
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
|
||||
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
|
||||
host=com.android.calendar`; `MainActivity` → `launchMode="singleTop"`
|
||||
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
|
||||
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
|
||||
|
||||
**Prefs:**
|
||||
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
|
||||
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
|
||||
|
||||
**Data layer (`data/reminders/`):**
|
||||
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
|
||||
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
|
||||
title, location, isAllDay
|
||||
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
|
||||
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
|
||||
`STATE_SCHEDULED AND ALARM_TIME <= now`;
|
||||
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
|
||||
`SecurityException` → Log (Write-Recht optional)
|
||||
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
|
||||
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
|
||||
Category EVENT), Content-PendingIntent auf `MainActivity` mit
|
||||
eventId/begin/end
|
||||
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
|
||||
|
||||
**Receiver:**
|
||||
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
|
||||
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
|
||||
Notifications systemseitig geblockt; sonst posten → `markFired`
|
||||
|
||||
**UI:**
|
||||
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
|
||||
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
|
||||
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
|
||||
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
|
||||
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
|
||||
setzen `reminder_onboarding_done`
|
||||
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
|
||||
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
|
||||
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
|
||||
onNewIntent) in Compose-State
|
||||
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
|
||||
Permission-Request beim Einschalten) + Duplikat-Hinweistext
|
||||
|
||||
**Abschluss:**
|
||||
- [x] `./gradlew lint test assembleDebug` grün
|
||||
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
|
||||
On-Device-Review
|
||||
@@ -1,150 +0,0 @@
|
||||
# Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Die Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene
|
||||
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
|
||||
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
|
||||
(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
|
||||
Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import
|
||||
(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`),
|
||||
beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine
|
||||
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
|
||||
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||
On-Device-Review (gemeinsam mit Branch 2).
|
||||
|
||||
**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein
|
||||
`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist
|
||||
`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
|
||||
gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf
|
||||
`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon
|
||||
bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das
|
||||
Share-Intent liegt in einer dünnen Android-Schicht
|
||||
(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle →
|
||||
`IcsWriter` → `OutputStream` verdrahtet.
|
||||
|
||||
**Recherche-Befunde (Codebase, 2026-06-18):**
|
||||
|
||||
1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
|
||||
`kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
|
||||
`domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
|
||||
`UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
|
||||
nutzt `SimpleRecurrence.toRRule()` direkt.
|
||||
2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder
|
||||
geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID
|
||||
matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt
|
||||
**diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch
|
||||
unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release).
|
||||
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
|
||||
`EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein
|
||||
Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`).
|
||||
Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten,
|
||||
`EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller
|
||||
übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
|
||||
erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
|
||||
Events behalten ihre Originalzone.
|
||||
|
||||
**Leitentscheidungen:**
|
||||
|
||||
1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
|
||||
- **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
|
||||
(Tag-danach). Keine Zone — trivial korrekt.
|
||||
- **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein
|
||||
Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei.
|
||||
- **Getimt, wiederkehrend** → `DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>`.
|
||||
Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein
|
||||
„wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone
|
||||
liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
|
||||
`kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
|
||||
- **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
|
||||
Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
|
||||
(`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht
|
||||
RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als
|
||||
bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports),
|
||||
kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig".
|
||||
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
|
||||
(z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
|
||||
wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
|
||||
`event-id + DTSTART` (`<id>-<dtstart>@calendula`), damit derselbe Bestand
|
||||
über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht
|
||||
verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt
|
||||
(kein Migrations-Sweep über fremde Kalender).
|
||||
3. **Manueller Export, kein Background.** Backup via
|
||||
`ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
|
||||
`calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
|
||||
einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
|
||||
geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
|
||||
Auto-Backup bleibt explizit Roadmap-`later`).
|
||||
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
|
||||
gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
|
||||
Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit,
|
||||
damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren
|
||||
kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien.
|
||||
*Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs
|
||||
Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
|
||||
für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
|
||||
wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
|
||||
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
|
||||
die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
|
||||
`LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den
|
||||
Remindern (DISPLAY, `TRIGGER` = `-PT<min>M`), `STATUS`
|
||||
(CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE),
|
||||
`UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID-
|
||||
Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in
|
||||
Branch 2 nicht auch wieder lesen kann.
|
||||
6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF +
|
||||
Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden,
|
||||
`PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht
|
||||
(`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
|
||||
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
|
||||
(`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
|
||||
LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
|
||||
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
|
||||
+ isAllDay + zoneId, recurrenceRule?, location, description,
|
||||
reminderMinutes, status, availability, uid, calendarName) — entkoppelt
|
||||
vom Provider-Modell
|
||||
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
|
||||
`VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
|
||||
`IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
|
||||
Reminder, Escaping)
|
||||
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
|
||||
+ Stabilitätstest
|
||||
|
||||
**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
|
||||
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
|
||||
DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
|
||||
`EventExportProjection`; Datasource-Methode `exportableEvents()` +
|
||||
Repository `exportEvents()`; Test `IcsExportMapperTest`
|
||||
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
|
||||
Create
|
||||
|
||||
**Android-Export-Schicht:**
|
||||
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
|
||||
(FileProvider-Cache) als UTF-8
|
||||
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
|
||||
Event (one-off) → Cache-File über `FileProvider` → `ACTION_SEND`
|
||||
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
|
||||
`ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
|
||||
(Plural „Exported N events")
|
||||
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
|
||||
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
|
||||
Fehler + Plural, dateierter Default-Name
|
||||
|
||||
**Abschluss:**
|
||||
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
|
||||
- [x] CHANGELOG (`[Unreleased]`) ergänzt
|
||||
- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von
|
||||
Branch 2 (`feat/ics-import`)
|
||||
|
||||
**Offene Detail-Calls (vor Review klären, nicht-blockierend):**
|
||||
- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR`
|
||||
(statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt.
|
||||
- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender.
|
||||
- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen
|
||||
(`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
|
||||
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
|
||||
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
|
||||
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
|
||||
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
|
||||
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
|
||||
Kalender. Beide Branches landen in **einem** Release v2.7.0.
|
||||
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||
On-Device-Review.
|
||||
|
||||
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter` —
|
||||
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
|
||||
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
|
||||
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
|
||||
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
|
||||
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
|
||||
Repository-Methode `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
|
||||
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
|
||||
Event-Anzahl.
|
||||
|
||||
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
|
||||
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
|
||||
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
|
||||
den Rest der Datei durch.
|
||||
|
||||
**Leitentscheidungen:**
|
||||
|
||||
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
|
||||
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
|
||||
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
|
||||
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
|
||||
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
|
||||
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
|
||||
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
|
||||
- `…T…Z` → UTC-Instant.
|
||||
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
|
||||
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID` →
|
||||
Gerätezone als Fallback (+ Warnung).
|
||||
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
|
||||
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
|
||||
seine `TZID` nicht in der OS-DB ist).
|
||||
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
|
||||
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null` →
|
||||
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
|
||||
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
|
||||
Datei → freundlicher „nichts gefunden"-Hinweis.
|
||||
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
|
||||
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
|
||||
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
|
||||
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
|
||||
verlustfrei. Events ohne UID bekommen beim Insert eine frische
|
||||
(`UUID@calendula`, wie `insertEvent`).
|
||||
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
|
||||
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
|
||||
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
|
||||
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
|
||||
Notification-Deep-Link).
|
||||
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
|
||||
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP` → `EventStatus`/`Availability`.
|
||||
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
|
||||
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
|
||||
vorhanden).
|
||||
|
||||
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
|
||||
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
|
||||
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
|
||||
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
|
||||
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
|
||||
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
|
||||
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
|
||||
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
|
||||
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
|
||||
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
|
||||
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
|
||||
gequotete Params)
|
||||
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
|
||||
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
|
||||
- [x] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
|
||||
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
|
||||
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
|
||||
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
|
||||
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
|
||||
|
||||
**Datenschicht (`data/calendar/` + `data/ics/`):**
|
||||
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
|
||||
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
|
||||
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
|
||||
- [x] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
|
||||
Fake-Datasource
|
||||
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
|
||||
|
||||
**Intent + Routing:**
|
||||
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
|
||||
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
|
||||
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
|
||||
>1 → Bulk-Import-Screen; 0 → Hinweis
|
||||
|
||||
**UI:**
|
||||
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
|
||||
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
|
||||
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
|
||||
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
|
||||
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
|
||||
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
|
||||
leere-Datei-Hinweis
|
||||
|
||||
**Abschluss:**
|
||||
- [x] `./gradlew lint test assembleDebug` grün
|
||||
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
|
||||
Branches gemerged sind und On-Device-Review durch ist
|
||||
@@ -1,406 +0,0 @@
|
||||
# Calendula - V1 Design Spec
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Draft for review
|
||||
**Author:** Jean-Luc Makiola (with Claude)
|
||||
|
||||
## 1. Motivation & Goal
|
||||
|
||||
Eine Android-native, Open-Source-Kalender-App im Material 3 Expressive Design.
|
||||
Schließt die Lücke, dass es derzeit keinen optisch zeitgemäßen Kalender abseits
|
||||
von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
||||
|
||||
**Was die App NICHT macht:**
|
||||
- Eigene CalDAV/iCal-Synchronisation - das übernimmt der Android `CalendarContract`
|
||||
bzw. Drittsoftware wie DAVx5
|
||||
- Eigene lokale Event-DB - alle Daten leben im `CalendarContract`
|
||||
|
||||
**Was die App macht:**
|
||||
- Schöne, moderne UI über Android-Bordmittel
|
||||
- Liest aus `CalendarContract` (alle Quellen: Nextcloud/CalDAV via DAVx5,
|
||||
Google, lokal, WebCal-Subscriptions)
|
||||
- V1 ist read-only; Schreibrechte kommen in V2
|
||||
|
||||
## 2. Scope - V1 MVP (Variante "B")
|
||||
|
||||
### In-Scope
|
||||
- 3 Hauptansichten: Monat, Woche, Tag
|
||||
- Event-Detail-Sheet (read-only Detailansicht)
|
||||
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
||||
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
|
||||
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
||||
- Permission-Flow für `READ_CALENDAR`
|
||||
- Empty-States und Error-Recovery
|
||||
- DE + EN Lokalisierung
|
||||
- Tests + CI ab Tag 1
|
||||
|
||||
### Out-of-Scope (V2+)
|
||||
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
|
||||
- Event-Create/Edit/Delete (V2)
|
||||
- Home-Screen-Widget
|
||||
- Volltextsuche
|
||||
- Quick-Add
|
||||
- Notifications/Reminders (System macht das schon, nicht doppeln)
|
||||
- Tablet-/Foldable-spezifische Layouts
|
||||
- iOS (Kotlin-Native ist explizit Android-only)
|
||||
|
||||
## 3. Tech Stack
|
||||
|
||||
| Layer | Wahl | Begründung |
|
||||
|---|---|---|
|
||||
| Sprache | Kotlin 2.0+ | Android-Native-Standard |
|
||||
| UI | Jetpack Compose + Material3 Expressive (1.5+) | Echter M3 Expressive Support |
|
||||
| Min SDK | 29 (Android 10) | Modern, keine Compat-Pfade |
|
||||
| Target SDK | 36 (Android 16) | Aktuell, wie HouseHoldKeaper CI |
|
||||
| DI | Hilt | Industriestandard |
|
||||
| Persistenz Prefs | DataStore (Preferences) | Theme, Wochenstart, Filter-State |
|
||||
| Persistenz Daten | keine | Source of Truth bleibt `CalendarContract` |
|
||||
| Datum/Zeit | `kotlinx.datetime` (Domain), `java.time` an Provider-Grenze | Saubere API |
|
||||
| Navigation | Compose Navigation, Single-Activity | Standard |
|
||||
| Lokalisierung | Android Resources (`strings.xml`) + Plurals | DE + EN ab V1 |
|
||||
| Tests | JUnit5, Truth, Turbine, Compose UI Test | JVM-first, Instrumented nur für ContentResolver-Integration |
|
||||
| Build | Gradle Kotlin DSL + Version Catalog | Lesbar, typsicher |
|
||||
| CI | Gitea Workflows (adaptiert von HouseHoldKeaper) | Gleiche Konvention wie restliche Projekte |
|
||||
|
||||
### Permissions
|
||||
- `READ_CALENDAR` (einzige Runtime-Permission)
|
||||
- `android.permission.QUERY_ALL_PACKAGES` **nicht** nötig (Maps-Intent geht ohne)
|
||||
|
||||
### App-Identifier
|
||||
- **App-Name:** Calendula (vom lateinischen *kalendae* - "der erste Tag des Monats", Wortwurzel von "Kalender"; gleichzeitig der Name der Ringelblume)
|
||||
- **Package:** `de.jeanlucmakiola.calendula`
|
||||
- Convention identisch zu `HouseHoldKeaper`: `de.jeanlucmakiola.<app_name>`
|
||||
|
||||
## 4. Architektur
|
||||
|
||||
### Modul-Struktur
|
||||
Single Gradle module `:app` für V1. Feature-Split (`:core`, `:feature-*`) erst
|
||||
wenn nötig - YAGNI.
|
||||
|
||||
### Package-Layout
|
||||
```
|
||||
de.jeanlucmakiola.calendula/
|
||||
├── CalendulaApp.kt + MainActivity.kt
|
||||
├── data/ ContentResolver-Wrapper, Repositories
|
||||
├── domain/ Pure-Kotlin Models (Event, CalendarSource)
|
||||
└── ui/
|
||||
├── theme/ M3 Expressive Theme, Dynamic Color
|
||||
├── month/ Monatsansicht (Composable + ViewModel + State)
|
||||
├── week/ Wochenansicht
|
||||
├── day/ Tagesansicht
|
||||
├── detail/ Event-Detail-Sheet
|
||||
├── filter/ Kalender-Filter-Sheet
|
||||
├── settings/ Settings-Screen
|
||||
├── permission/ Permission-Request-Screen
|
||||
└── common/ Geteilte Composables (LoadingScreen-Helper, FailureScreen-Helper)
|
||||
```
|
||||
|
||||
### Layer-Verantwortlichkeiten
|
||||
- **data/**: Nur Layer der Android-Klassen kennt (`ContentResolver`, `Cursor`,
|
||||
`CalendarContract`). Mapped auf Domain-Modelle.
|
||||
- **domain/**: Pure Kotlin. Keine Android-Imports.
|
||||
- **ui/**: Compose-Code, ViewModels. Hängt von domain ab, niemals direkt an data.
|
||||
|
||||
## 5. Datenfluss & Domain-Modell
|
||||
|
||||
### Domain-Modelle (pure Kotlin)
|
||||
|
||||
```kotlin
|
||||
data class CalendarSource(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val accountName: String, // z.B. "jlmak@nextcloud.example.com"
|
||||
val accountType: String, // z.B. "at.bitfire.davdroid" (DAVx5), "com.google" (Google), "LOCAL"
|
||||
val color: Int,
|
||||
val isVisibleInSystem: Boolean, // CalendarContract.Calendars.VISIBLE
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
val instanceId: Long, // Instances._ID (eindeutig pro Vorkommen)
|
||||
val eventId: Long, // Events._ID (gleich für alle Vorkommen)
|
||||
val calendarId: Long,
|
||||
val title: String,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
val isAllDay: Boolean,
|
||||
val color: Int, // Effektiv: Event.color ?: Calendar.color
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
val organizer: String?,
|
||||
val attendees: List<Attendee>,
|
||||
val rrule: String?, // Read-only: nur "wiederkehrt wöchentlich" anzeigen
|
||||
)
|
||||
|
||||
data class Attendee(val name: String, val email: String?, val status: AttendeeStatus)
|
||||
enum class AttendeeStatus { Accepted, Declined, Tentative, NeedsAction, Unknown }
|
||||
```
|
||||
|
||||
### Repository
|
||||
|
||||
```kotlin
|
||||
interface CalendarRepository {
|
||||
fun calendars(): Flow<List<CalendarSource>>
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation-Details:**
|
||||
- Hält `ContentObserver` auf `CalendarContract.CONTENT_URI` registriert
|
||||
- Bei Observer-Trigger: re-query, emit neuer Wert via SharedFlow
|
||||
- Queries laufen auf `Dispatchers.IO`
|
||||
- `instances(range)` nutzt `CalendarContract.Instances.CONTENT_BY_DAY_URI`
|
||||
oder `CONTENT_URI` mit Time-Range - Recurrence-Expansion macht der Provider
|
||||
- App-eigene "ausgeblendete Kalender-IDs" leben in DataStore als `Set<Long>`,
|
||||
kombinieren via `combine()` mit Calendar-Flow
|
||||
|
||||
### ViewModel-Pattern
|
||||
|
||||
Ein ViewModel pro Top-Level-Screen. State immer als sealed interface
|
||||
(siehe Section 7 - Loading/Failure/Success).
|
||||
|
||||
ViewModel kennt aktuelle "Cursor"-Position (welcher Monat/Woche/Tag) und
|
||||
fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
|
||||
|
||||
## 6. Screens & Menüs
|
||||
|
||||
### Hauptscreens
|
||||
|
||||
**S1 - Monatsansicht**
|
||||
- Zeigt einen Monat im Überblick
|
||||
- Pro Tag erkennbar: hat-Events / keine-Events, ggf. Andeutung über Anzahl/Farbe
|
||||
- Navigation: vorwärts/zurück zwischen Monaten
|
||||
- Tap auf Tag → Tagesansicht für diesen Tag
|
||||
- Heute deutlich markiert
|
||||
|
||||
**S2 - Wochenansicht**
|
||||
- Zeigt eine Woche mit Zeitschiene
|
||||
- Events auf ihrer Uhrzeit, Calendar-Farbe
|
||||
- Overlap-Events: nebeneinander aufgelöst
|
||||
- All-Day-Events extra dargestellt
|
||||
- Navigation: vorwärts/zurück zwischen Wochen
|
||||
- Tap Event → Event-Detail-Sheet
|
||||
|
||||
**S3 - Tagesansicht**
|
||||
- Eine Spalte, mehr Detail pro Event als Wochenansicht
|
||||
- All-Day-Events extra
|
||||
- Navigation: vorwärts/zurück zwischen Tagen
|
||||
- Tap Event → Event-Detail-Sheet
|
||||
|
||||
**S4 - Event-Detail-Sheet (Bottom-Sheet, ModalBottomSheet)**
|
||||
- Pflicht-Inhalte: Titel, Start/Ende oder "Ganztägig", Kalender-Zugehörigkeit
|
||||
- Konditional: Ort (Tap → Maps-Intent), Beschreibung, Teilnehmer, RRULE-Hinweis
|
||||
- Dismissable via Drag oder Back-Geste
|
||||
|
||||
### Menüs
|
||||
|
||||
**M1 - View-Switcher**
|
||||
- Wechsel zwischen Monat / Woche / Tag
|
||||
- Immer erreichbar von allen Hauptansichten
|
||||
- State persistent (zuletzt aktive Ansicht)
|
||||
|
||||
**M2 - Heute**
|
||||
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
|
||||
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
|
||||
- Erreichbar von allen Hauptansichten
|
||||
|
||||
**M3 - Kalender-Filter (Bottom-Sheet)**
|
||||
- Sichtbare Kalender ein-/ausblenden
|
||||
- Gruppiert pro Account (Nextcloud / Local / Google / …)
|
||||
- Pro Eintrag: Name, Calendar-Farbe
|
||||
- Persistiert in DataStore
|
||||
- Erreichbar von allen Hauptansichten
|
||||
|
||||
**M4 - Settings**
|
||||
- Theme: System / Light / Dark
|
||||
- Dynamic Color: an/aus (auto disabled wenn API < 31)
|
||||
- Wochenstart: Auto (aus Locale) / Mo / So
|
||||
- Sprache: Auto / DE / EN
|
||||
- About: Version, Lizenz, Link zum Quellcode (Gitea)
|
||||
|
||||
### Spezial-Flows
|
||||
|
||||
**F1 - Erst-Start / Permission-Flow**
|
||||
- Beim ersten App-Start: `READ_CALENDAR`-Request
|
||||
- Erklärungs-Text: "Wir lesen nur deinen Gerätekalender - keine Daten verlassen das Gerät"
|
||||
- Bei Denial: friendlicher Recovery-Screen mit Re-Request-Button + Link zu System-Settings
|
||||
|
||||
**F2 - Empty-State (keine Kalender / keine Events)**
|
||||
- Keine Kalender konfiguriert: Hinweis "Füge in DAVx5 oder System-Settings einen Kalender hinzu" mit Intent-Link zu System-Calendar-Settings
|
||||
- Kalender da, aber aktuelle Ansicht leer: dezent, kein nerviger Placeholder
|
||||
|
||||
**F3 - Reaktion auf externe Änderungen**
|
||||
- DAVx5/System-Calendar ändert sich → App aktualisiert sich automatisch via ContentObserver
|
||||
- Kein manueller Pull-to-Refresh
|
||||
|
||||
## 7. UI-State-Modell: Loading / Failure / Success
|
||||
|
||||
**Pflicht-Pattern für jeden Screen.** Keine Ausnahmen.
|
||||
|
||||
### ViewModel-State
|
||||
|
||||
```kotlin
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
data class Success(
|
||||
val month: YearMonth,
|
||||
val eventsPerDay: Map<LocalDate, List<EventInstance>>,
|
||||
val visibleCalendars: List<CalendarSource>,
|
||||
) : MonthUiState
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
PermissionRevoked, // → Re-Request-Screen
|
||||
NoCalendarsConfigured, // → Empty-State mit Intent zu System-Settings
|
||||
ProviderUnavailable, // → Retry-Screen
|
||||
EventNotFound, // → nur für Event-Detail-Sheet
|
||||
Unknown, // → Fallback
|
||||
}
|
||||
```
|
||||
|
||||
### Composable-Dispatch
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MonthScreen(viewModel: MonthViewModel) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is MonthUiState.Loading -> MonthLoadingScreen()
|
||||
is MonthUiState.Failure -> MonthFailureScreen(s.reason, onRetry = viewModel::retry)
|
||||
is MonthUiState.Success -> MonthSuccessScreen(s, ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pflicht-Composables pro Screen
|
||||
|
||||
| Screen | Loading | Failure-Varianten | Success |
|
||||
|---|---|---|---|
|
||||
| Monat | Skelett-Grid (Shimmer) | Permission, NoCalendars, Provider | Grid mit Events |
|
||||
| Woche | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
|
||||
| Tag | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
|
||||
| Event-Detail | kompakter Skelett-Sheet | EventNotFound, Provider | Voll-Detail |
|
||||
| Kalender-Filter | Skelett-Liste | Provider | Liste |
|
||||
| Settings | sofort (DataStore ist instant) | - | direkt |
|
||||
|
||||
**Regeln:**
|
||||
- Loading ist ein bewusst gestalteter Screen (Skeleton + Shimmer), kein loser Spinner
|
||||
- Failure ist ein eigener Screen mit Erklärung + Recovery-Action, kein Toast
|
||||
- Beim UI-Design später: alle drei Varianten pro Screen skizzieren, nie nur Success
|
||||
- Tests: pro Screen mindestens `renders_loading`, `renders_failure`, `renders_success`
|
||||
|
||||
## 8. Error Handling & Edge Cases
|
||||
|
||||
### Philosophie
|
||||
Calendar-Apps dürfen niemals an leeren/malformen Daten crashen. Defensive
|
||||
Validierung im Repository, kaputte Instanzen still droppen + via `Log.w` loggen.
|
||||
|
||||
### Konkrete Fehlerfälle
|
||||
| Fall | Verhalten |
|
||||
|---|---|
|
||||
| ContentResolver-Query wirft (Permission revoked zur Laufzeit) | State → `Failure(PermissionRevoked)`, UI zeigt Re-Request |
|
||||
| Calendar `displayName` null | Fallback "(Unbenannter Kalender)" |
|
||||
| Event `title` null/leer | Fallback "(Ohne Titel)" |
|
||||
| `dtend < dtstart` | Event droppen, Warn-Log |
|
||||
| `dtstart` vor Unix-Epoch | Event droppen, Warn-Log |
|
||||
| Maps-Intent fehlt | SnackBar "Keine Karten-App installiert" |
|
||||
| DataStore-IO-Fehler | Defaults verwenden, weiter, Warn-Log |
|
||||
|
||||
### Edge Cases im UI
|
||||
- **All-Day-Events über mehrere Tage:** in Wochen-/Tagesansicht über mehrere
|
||||
Tage gespannter All-Day-Strip
|
||||
- **Events über Mitternacht:** in Wochen-/Tagesansicht am Folgetag fortsetzen
|
||||
- **Instant Events (start == end):** Mindesthöhe rendern für Tap-Target
|
||||
- **Viele Events an einem Tag:** in Monatsansicht "+N more" statt Overflow
|
||||
- **Timezones:** alle Berechnungen in Geräte-Local-TZ, außer all-day = floating
|
||||
|
||||
## 9. i18n & Accessibility
|
||||
|
||||
### i18n
|
||||
- `res/values/strings.xml` = englische Master-Strings
|
||||
- `res/values-de/strings.xml` = deutsche Übersetzungen
|
||||
- Alle Strings extrahiert, auch Plurals (`<plurals>` für "1 Event" / "N Events")
|
||||
- Wochentags-/Monatsnamen via `java.time.format.DateTimeFormatter` mit aktiver
|
||||
Locale (kein Hardcoding)
|
||||
- Sprach-Override aus Settings via `AppCompatDelegate.setApplicationLocales`
|
||||
|
||||
### Accessibility (V1-Minimum)
|
||||
- Alle interaktiven Elemente: `contentDescription`, Tap-Target ≥ 48dp
|
||||
- Event-Items: semantisches Label "Titel, Start-Zeit, Dauer, Kalender X"
|
||||
- Calendar-Color **immer** mit Label/Form kombiniert (nie nur-Farbe-Information)
|
||||
- Dynamic-Text-Size respektiert (keine fixen sp-Werte für Text)
|
||||
- Hoher-Kontrast: M3-Theme reagiert automatisch
|
||||
- TalkBack-Smoke-Tests im UI-Test-Plan
|
||||
|
||||
## 10. Testing
|
||||
|
||||
Best Practices, kein Diskussionsbedarf:
|
||||
- **Unit-Tests:** JUnit5 + Truth + Turbine. Repository, ViewModels, Date/Time-Helpers,
|
||||
ContentResolver-Wrapper (mit Mock-Cursor)
|
||||
- **UI-Tests:** Compose UI Test pro Screen, mindestens `renders_loading` /
|
||||
`renders_failure` / `renders_success` + 1-2 Interaktions-Tests
|
||||
- **Coverage-Ziel:** pragmatisch ~70% lines, 100% für Repository + Date-Logik.
|
||||
Kein Coverage-Gate in CI, aber Pflicht-Run
|
||||
- **Instrumented-Tests:** nur für ContentResolver-Integration (echte
|
||||
CalendarContract-Queries auf Emulator)
|
||||
|
||||
## 11. CI/CD
|
||||
|
||||
Adaption der `HouseHoldKeaper`-Pipeline, nur Flutter-Steps durch Gradle ersetzt.
|
||||
|
||||
### `.gitea/workflows/ci.yaml` (push + PR)
|
||||
- Setup Java 17 + Android SDK 36
|
||||
- `./gradlew lint`
|
||||
- `./gradlew test`
|
||||
- `./gradlew assembleDebug`
|
||||
- Trivy Filesystem-Scan (HIGH/CRITICAL, `continue-on-error` wie HHK)
|
||||
|
||||
### `.gitea/workflows/release.yaml` (auf Git-Tags)
|
||||
- Alles aus `ci.yaml`
|
||||
- Version aus Git-Tag in `app/build.gradle.kts`:
|
||||
- `versionName = "${tag#v}"`
|
||||
- `versionCode = MAJOR*10000 + MINOR*100 + PATCH` (HHK-Konvention)
|
||||
- Keystore aus Gitea Secrets (`KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`)
|
||||
- `./gradlew assembleRelease`
|
||||
- F-Droid-Pipeline 1:1 wie HHK: Hetzner-Sync, `fdroid update -c`, Re-Upload
|
||||
|
||||
### Repo-Konventionen
|
||||
- `CHANGELOG.md` wird beim Taggen gepflegt (patch/minor/major)
|
||||
- `fdroid-metadata/de.jeanlucmakiola.calendula/` Verzeichnis-Struktur
|
||||
- `LICENSE` = MIT, Jean-Luc Makiola, 2026
|
||||
- `.planning/` mit `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md`
|
||||
|
||||
## 12. Design-Decisions (gelöst)
|
||||
|
||||
### Theme-Seed-Color (Fallback wenn kein Dynamic Color verfügbar)
|
||||
**`0xFF5C6B7A`** - desaturiertes Schiefer-Blaugrau.
|
||||
- Bewusst anders als HouseHoldKeaper's Sage (`0xFF7A9A6D`), damit beide Apps unterscheidbar sind
|
||||
- Mid-Saturation → M3 Expressive Dynamic Color generiert daraus eine ausgewogene Palette
|
||||
- Cool aber nicht kalt → passt zu "modern functional"
|
||||
- Funktioniert in Light- und Dark-Theme
|
||||
|
||||
### App-Icon (Adaptive Launcher)
|
||||
**Statische "1" auf M3-Expressive-Squircle.**
|
||||
- **Foreground:** Stilisierte Ziffer "1" (bold), zentriert auf einem Squircle
|
||||
- **Background:** Seed-Color `0xFF5C6B7A` (slate)
|
||||
- **Bedeutung:** Die "1" referenziert *kalendae* (der erste Tag des Monats) - Wortwurzel sowohl von "Kalender" als auch "Calendula". Die App heisst Calendula, aber das Icon zeigt klar: dies ist ein Kalender.
|
||||
- Adaptive-Icon-Spec: Foreground 432dp x 432dp Safe-Zone in 108dp Tile, Background fest
|
||||
- Vektor-basiert (kein PNG), in `res/drawable/ic_launcher_*.xml` als VectorDrawable
|
||||
|
||||
### Konkretes UI-Layout pro Screen
|
||||
**Bewusst offen** - wird in eigener UI-Design-Iteration nach Spec-Approval entworfen
|
||||
(Mockups pro Screen, alle drei States, vor Implementation).
|
||||
|
||||
## 13. Nächste Schritte nach Spec-Approval
|
||||
|
||||
1. Implementation-Plan via `writing-plans`-Skill aus diesem Spec ableiten
|
||||
2. Initiales Gradle-Projekt-Scaffolding
|
||||
3. Tooling: Lint-Config, Detekt o.ä., CI-Workflows initial
|
||||
4. Iterative UI-Design-Phase (Mockups pro Screen, alle drei States,
|
||||
bevor implementiert wird)
|
||||
5. Feature-by-Feature-Implementation gegen den Plan
|
||||
16
fastlane/metadata/android/en-US/changelogs/21301.txt
Normal file
16
fastlane/metadata/android/en-US/changelogs/21301.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
### Added
|
||||
- Create events from other apps and widgets. Calendula now registers the
|
||||
standard "insert event" intent, so other apps and home-screen widgets (such as
|
||||
the Todo Agenda widget) can hand off to Calendula to create a new event — it
|
||||
opens the new-event form prefilled with whatever they passed (title, time,
|
||||
location, description). Thanks to @dschuermann for the suggestion ([#30]).
|
||||
|
||||
### Fixed
|
||||
- Some recurring events could not be opened. Events in a series that started
|
||||
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||
— showed "Something went wrong" instead of opening. They now open normally, and
|
||||
show up in search again. Thanks to @dschuermann for the report ([#34]).
|
||||
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||
24-hour time, the dial for choosing an event's start and end time still showed
|
||||
AM/PM; it now matches your setting. Thanks to @abrossimow for the report
|
||||
([#27]).
|
||||
Reference in New Issue
Block a user