Compare commits
63 Commits
c8a4f90404
...
feat/day-v
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f51554f9 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 4e125e58d5 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 25ff7ce7ca |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
72
CHANGELOG.md
72
CHANGELOG.md
@@ -5,9 +5,55 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.13.1] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### 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
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
- Set more than one default reminder per calendar. A calendar's default
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
reminders — and the global defaults under Settings → Notifications — can now
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
hold several lead times instead of just one, so new events can start with, say,
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
@@ -22,6 +68,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
date number — now opens that day, and the "today" button snaps the grid back
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
suggestions ([#18], [#20]).
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Month widget arrows and "today" button work again. On release builds the
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
@@ -38,6 +96,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
lands on just that one event and leaves the rest of the series untouched
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
([#16]).
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
## [2.12.0] — 2026-06-28
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
@@ -764,7 +827,14 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||||
|
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
[#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 +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21200
|
versionCode = 21301
|
||||||
versionName = "2.12.0"
|
versionName = "2.13.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||||
|
contacts, and only after the user enables it and grants this at runtime
|
||||||
|
(never requested at startup). Everything stays offline — birthdays and
|
||||||
|
other contact dates are mirrored one-way into local calendars; contacts
|
||||||
|
are never written and nothing leaves the device (the app has no INTERNET
|
||||||
|
permission). See docs/design/contact-special-dates.md.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<!--
|
<!--
|
||||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
@@ -103,6 +112,17 @@
|
|||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
</intent-filter>
|
</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"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand).
|
||||||
CrashReporter.install(this)
|
CrashReporter.install(this)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
|
reconcileSpecialDates()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,4 +47,21 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
|
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||||
|
* orphaned work after the feature is turned off and re-schedules after a
|
||||||
|
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||||
|
* refresh, so no immediate run is needed here.
|
||||||
|
*/
|
||||||
|
private fun reconcileSpecialDates() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
SpecialDatesScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.CalendarContract
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
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.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
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.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// by CalendarHost's import flow.
|
// by CalendarHost's import flow.
|
||||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
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
|
// 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
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = 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" }
|
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
|
* 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/
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
|||||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||||
|
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||||
|
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||||
|
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||||
|
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||||
|
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||||
|
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||||
|
*/
|
||||||
|
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||||
|
var year = today.year
|
||||||
|
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||||
|
repeat(8) {
|
||||||
|
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||||
|
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||||
|
return LocalDate.of(today.year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
|
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||||
|
* hue (the managed special-dates calendars), so they can't drift from the
|
||||||
|
* swatches offered in the colour picker.
|
||||||
|
*/
|
||||||
|
object CalendarColorPalette {
|
||||||
|
val Red = 0xFFD50000.toInt()
|
||||||
|
val Orange = 0xFFE67C00.toInt()
|
||||||
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
|
val Green = 0xFF33B679.toInt()
|
||||||
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
|
val Blue = 0xFF039BE5.toInt()
|
||||||
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
|
val Graphite = 0xFF616161.toInt()
|
||||||
|
|
||||||
|
/** The full palette, in swatch-row order. */
|
||||||
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
|
}
|
||||||
@@ -24,10 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -97,6 +101,55 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
|
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||||
|
*/
|
||||||
|
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||||
|
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||||
|
* detect when the user deleted one in system settings.
|
||||||
|
*/
|
||||||
|
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||||
|
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||||
|
*/
|
||||||
|
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||||
|
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||||
|
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||||
|
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||||
|
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||||
|
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||||
|
*/
|
||||||
|
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the reminders on **every managed** event in [calendarId] with
|
||||||
|
* [minutes] (all-day lead times), each encoded so it fires at
|
||||||
|
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
|
||||||
|
* special-dates section so a reminder change applies to existing events too,
|
||||||
|
* not just future ones — the managed calendars treat reminders as a
|
||||||
|
* calendar-level setting. Skips any non-managed (user) event in the calendar.
|
||||||
|
*/
|
||||||
|
fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
@@ -292,6 +345,140 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
// The marker that identifies this as our special-dates calendar,
|
||||||
|
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||||
|
// holds the description, so the marker lives in CAL_SYNC2.
|
||||||
|
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create managed calendar")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||||
|
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||||
|
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val type = typeForMarker(c.getString(1)) ?: continue
|
||||||
|
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
),
|
||||||
|
// Only our own mirror events (UID prefix), so a user event that somehow
|
||||||
|
// landed in this calendar (e.g. an .ics import) is never seen as
|
||||||
|
// "existing but not desired" and deleted by the diff.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
add(
|
||||||
|
ManagedEventRow(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
uid = uid,
|
||||||
|
title = c.getString(2).orEmpty(),
|
||||||
|
dtStartMillis = c.getLong(3),
|
||||||
|
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||||
|
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = buildEventInsertValues(form, uid, times)
|
||||||
|
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
|
?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||||
|
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||||
|
// birthday reminder keeps their change. Encoded against the upcoming
|
||||||
|
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||||
|
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
insertReminderRows(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||||
|
// Only our own mirror events, so a stray user event in this calendar
|
||||||
|
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val eventId = c.getLong(0)
|
||||||
|
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||||
|
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||||
|
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reconcileReminders(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
@@ -575,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
|
||||||
)
|
|
||||||
// A globally-unique UID so a later .ics backup/restore can identify
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
// the event and not duplicate it on re-import (the provider leaves
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
// this null for events it didn't sync). Older rows without one fall
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
val values = buildEventInsertValues(
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
form,
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
uid = "${UUID.randomUUID()}@calendula",
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
).toContentValues().apply {
|
||||||
// (and no DTEND), one-off rows carry DTEND.
|
|
||||||
if (form.rrule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, form.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
|
||||||
}
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
|
||||||
form.location.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
// A null colour just leaves both columns unset (the event inherits
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
// its calendar's colour), so only the key/raw cases are written.
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
when {
|
when {
|
||||||
@@ -615,10 +782,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
encodedReminders(form, allDayReminderTimeMinutes)
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
.forEach { minutes ->
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
|
insertAttendees(eventId, form.attendees)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||||
|
* (spec §8): the event exists at this point — a reminder that fails to
|
||||||
|
* attach is logged, not surfaced as a failed create.
|
||||||
|
*/
|
||||||
|
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
|
||||||
|
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
|
||||||
|
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||||
|
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||||
|
encodedMinutes.forEach { minutes ->
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
@@ -628,11 +810,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Guests are best-effort like reminders: a row that fails to attach is
|
}
|
||||||
// logged, not surfaced as a failed create. Calendula never sends an
|
|
||||||
// invitation — it only writes the rows; the backend decides delivery.
|
/**
|
||||||
insertAttendees(eventId, form.attendees)
|
* A managed all-day event's reminders, encoded so they fire at
|
||||||
return eventId
|
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
|
||||||
|
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
|
||||||
|
* it directly would sample a stale timezone offset, so the offset is sampled
|
||||||
|
* at the next occurrence of the anchor's month/day instead.
|
||||||
|
*/
|
||||||
|
private fun managedAllDayReminderMinutes(
|
||||||
|
anchor: LocalDate,
|
||||||
|
reminders: List<Int>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<Int> {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
|
||||||
|
return reminders
|
||||||
|
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(
|
override fun updateEvent(
|
||||||
@@ -1038,6 +1234,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||||
|
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||||
|
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||||
|
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||||
|
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||||
|
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
@@ -1047,6 +1254,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column and namespace for the special-dates marker (shared with the
|
||||||
|
* calendar projection, which reads it into [CalendarSource.isManaged]).
|
||||||
|
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
|
||||||
|
*/
|
||||||
|
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
|
||||||
|
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead/behind a search looks for a recurring event's nearest
|
* How far ahead/behind a search looks for a recurring event's nearest
|
||||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||||
|
// marker (only meaningful on the local calendars the app owns). This is
|
||||||
|
// the source of truth for the editor lock — independent of any stored
|
||||||
|
// preference that a backup restore could have wiped.
|
||||||
|
isManaged = isLocal &&
|
||||||
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
if (begin < 0L) {
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// 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)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin
|
||||||
} else {
|
} else {
|
||||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
if (rawEnd < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
rawEnd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
|
|||||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
|||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a brand-new Events row: identity, times (the provider's
|
||||||
|
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||||
|
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||||
|
* the user-event and managed-event insert paths, which differ only in the
|
||||||
|
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||||
|
* layers on top.
|
||||||
|
*/
|
||||||
|
internal fun buildEventInsertValues(
|
||||||
|
form: EventForm,
|
||||||
|
uid: String,
|
||||||
|
times: EventWriteTimes,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dirty-checked column values for updating an existing Events row: only what
|
* Dirty-checked column values for updating an existing Events row: only what
|
||||||
* the user actually changed is written, so untouched fields can't stomp
|
* the user actually changed is written, so untouched fields can't stomp
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||||
|
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||||
|
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||||
|
*/
|
||||||
|
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||||
|
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||||
|
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||||
|
* the desired state to decide whether a targeted update is needed.
|
||||||
|
*/
|
||||||
|
data class ManagedEventRow(
|
||||||
|
val eventId: Long,
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
@@ -15,9 +15,17 @@ internal object CalendarProjection {
|
|||||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
// so the mapper only reads it for local calendars).
|
// so the mapper only reads it for local calendars).
|
||||||
DESCRIPTION_COLUMN,
|
DESCRIPTION_COLUMN,
|
||||||
|
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||||
|
// uses to recognise its own managed calendars, independent of any
|
||||||
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
|
MANAGED_MARKER_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||||
|
|
||||||
|
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||||
|
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
|
const val IDX_MANAGED_MARKER = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
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)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
if (dtStart < 0L) return null
|
|
||||||
val end = when {
|
val end = when {
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the localized calendar names/templates and per-type colours the
|
||||||
|
* mirror creates its calendars with. The colours are picked from the shared
|
||||||
|
* palette so managed calendars look native alongside user calendars.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SpecialDatesCalendarSpec {
|
||||||
|
|
||||||
|
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun color(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||||
|
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||||
|
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||||
|
* device's contacts. Read-only and offline — the one-way source for the
|
||||||
|
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||||
|
* the permission is absent so the sync can degrade to a stalled state rather
|
||||||
|
* than crash.
|
||||||
|
*/
|
||||||
|
interface ContactSpecialDatesDataSource {
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||||
|
fun readSpecialDates(): List<ContactSpecialDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||||
|
fun Context.hasContactsPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||||
|
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||||
|
else -> SpecialDateType.Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||||
|
|
||||||
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
|
if (!hasPermission()) return emptyList()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// A contact can carry the same date more than once (multiple raw contacts
|
||||||
|
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||||
|
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||||
|
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||||
|
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
|
"${ContactsContract.Data._ID} ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
|
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||||
|
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||||
|
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
|
val date = ContactSpecialDate(
|
||||||
|
lookupKey = lookup,
|
||||||
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
|
type = type,
|
||||||
|
month = parts.month,
|
||||||
|
day = parts.day,
|
||||||
|
year = parts.year,
|
||||||
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (seen.add(date.managedUid())) result += date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ContactSpecialDates"
|
||||||
|
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
ContactsContract.Data.LOOKUP_KEY,
|
||||||
|
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||||
|
Event.START_DATE,
|
||||||
|
Event.TYPE,
|
||||||
|
Event.LABEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||||
|
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||||
|
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||||
|
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||||
|
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||||
|
*/
|
||||||
|
object SpecialDatesScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "special-dates-sync"
|
||||||
|
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||||
|
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||||
|
private const val KEY_FOREGROUND = "foreground"
|
||||||
|
|
||||||
|
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||||
|
fun apply(context: Context, enabled: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||||
|
// Delay the first periodic run so it never overlaps an immediate run.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||||
|
* debounced inside the worker and use their own work name, so a frequent
|
||||||
|
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||||
|
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||||
|
* overlap.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context, foreground: Boolean = false) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||||
|
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||||
|
.build()
|
||||||
|
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||||
|
* the run for the settings status line; a missing permission parks the feature
|
||||||
|
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||||
|
*/
|
||||||
|
class SpecialDatesSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun syncEngine(): SpecialDatesSyncEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// A foreground resume can fire often — skip if we synced recently.
|
||||||
|
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||||
|
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when (val result = deps.syncEngine().sync()) {
|
||||||
|
// The engine re-checks the toggle, so already-queued work never
|
||||||
|
// revives a disabled feature; don't record a run either.
|
||||||
|
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||||
|
else -> {
|
||||||
|
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||||
|
SpecialDatesStalledReason.PermissionRevoked
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
prefs.recordSpecialDatesRun(now, stalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||||
|
// park the feature in a stalled state (surfaced in settings) instead
|
||||||
|
// of retrying with backoff forever.
|
||||||
|
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||||
|
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Special-dates sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SpecialDatesSync"
|
||||||
|
|
||||||
|
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||||
|
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||||
|
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized, per-type presentation the engine needs but can't derive purely
|
||||||
|
* (calendar display name, colour, and the default title template). Kept behind
|
||||||
|
* an interface so the engine's diff stays unit-testable without Android
|
||||||
|
* resources.
|
||||||
|
*/
|
||||||
|
interface SpecialDatesCalendarSpec {
|
||||||
|
fun displayName(type: SpecialDateType): String
|
||||||
|
fun color(type: SpecialDateType): Int
|
||||||
|
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored title template for [type], falling back to the localized default
|
||||||
|
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||||
|
* written) and the settings editor (what gets shown).
|
||||||
|
*/
|
||||||
|
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||||
|
type: SpecialDateType,
|
||||||
|
stored: Map<SpecialDateType, String>,
|
||||||
|
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||||
|
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||||
|
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||||
|
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||||
|
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||||
|
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SpecialDatesSyncEngine @Inject constructor(
|
||||||
|
private val contacts: ContactSpecialDatesDataSource,
|
||||||
|
private val calendars: CalendarDataSource,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val spec: SpecialDatesCalendarSpec,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||||
|
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||||
|
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||||
|
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||||
|
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||||
|
// the enabled flag inside the lock — a teardown always wins the race.
|
||||||
|
private val lifecycleMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||||
|
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||||
|
*/
|
||||||
|
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||||
|
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||||
|
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||||
|
|
||||||
|
val enabledTypes = prefs.specialDatesTypes.first()
|
||||||
|
val calendarByType = reconcileCalendars(enabledTypes)
|
||||||
|
|
||||||
|
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||||
|
val reminderCtx = readReminderContext()
|
||||||
|
val templates = prefs.specialDatesTitleTemplates.first()
|
||||||
|
val showYear = prefs.specialDatesShowYear.first()
|
||||||
|
|
||||||
|
calendarByType.forEach { (type, calendarId) ->
|
||||||
|
val template = spec.resolveTitleTemplate(type, templates)
|
||||||
|
syncType(
|
||||||
|
calendarId = calendarId,
|
||||||
|
type = type,
|
||||||
|
contactsOfType = desiredByType[type].orEmpty(),
|
||||||
|
template = template,
|
||||||
|
showYear = showYear,
|
||||||
|
reminderCtx = reminderCtx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SpecialDatesSyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the reminder default for a type's managed calendar and apply it to
|
||||||
|
* **all** its existing events too (not just future ones) — for managed
|
||||||
|
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||||
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
|
* [type] doesn't exist yet.
|
||||||
|
*/
|
||||||
|
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||||
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
|
val minutes = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
|
CalendarReminderOverride.None -> emptyList()
|
||||||
|
is CalendarReminderOverride.Minutes -> override.minutes
|
||||||
|
}
|
||||||
|
calendars.applyManagedCalendarReminders(
|
||||||
|
calendarId = calendarId,
|
||||||
|
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
minutes = minutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||||
|
suspend fun teardown() = lifecycleMutex.withLock {
|
||||||
|
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||||
|
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||||
|
* existing one, or the stored id if it still exists, else creating one) and
|
||||||
|
* that disabled types have none. Returns the calendar id per enabled type.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||||
|
val foundByType = calendars.findManagedCalendars()
|
||||||
|
.groupBy({ it.type }, { it.id })
|
||||||
|
.mapValues { it.value.first() }
|
||||||
|
val stored = prefs.specialDatesCalendars.first()
|
||||||
|
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||||
|
for (type in SpecialDateType.entries) {
|
||||||
|
// A stored id counts only if the calendar still exists (the user may
|
||||||
|
// have deleted it in system settings); otherwise adopt a found one.
|
||||||
|
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||||
|
?: foundByType[type]
|
||||||
|
if (type in enabledTypes) {
|
||||||
|
val id = existingId ?: createCalendar(type)
|
||||||
|
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
result[type] = id
|
||||||
|
} else {
|
||||||
|
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||||
|
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||||
|
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||||
|
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||||
|
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||||
|
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||||
|
// only set when the user hasn't already configured this calendar.
|
||||||
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
|
id,
|
||||||
|
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncType(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
contactsOfType: List<ContactSpecialDate>,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
) {
|
||||||
|
val built = contactsOfType.map { sd ->
|
||||||
|
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||||
|
}
|
||||||
|
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||||
|
val formByUid = built.associate { it.desired.uid to it.form }
|
||||||
|
diff.insertUids.forEach { uid ->
|
||||||
|
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||||
|
}
|
||||||
|
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||||
|
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildManagedEvent(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
sd: ContactSpecialDate,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
): BuiltManagedEvent {
|
||||||
|
val anchor = sd.anchorDate()
|
||||||
|
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||||
|
// The source year is static and correct on every occurrence (unlike age).
|
||||||
|
val year = if (showYear) sd.year else null
|
||||||
|
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
isAllDay = true,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
|
availability = Availability.Free,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
)
|
||||||
|
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||||
|
return BuiltManagedEvent(
|
||||||
|
desired = ManagedEventDesired(
|
||||||
|
uid = sd.managedUid(),
|
||||||
|
title = title,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
),
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed events are always all-day, so only the all-day defaults apply.
|
||||||
|
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||||
|
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||||
|
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||||
|
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderContext(
|
||||||
|
val allDayGlobal: List<Int>,
|
||||||
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||||
|
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||||
|
allDayOverrides[calendarId] ?: allDayGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||||
|
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||||
|
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed columns of a desired event, compared against the existing row. */
|
||||||
|
internal data class ManagedEventDesired(
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||||
|
|
||||||
|
internal data class ManagedDiff(
|
||||||
|
val insertUids: List<String>,
|
||||||
|
val updates: List<ManagedFieldUpdate>,
|
||||||
|
val deleteEventIds: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||||
|
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||||
|
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||||
|
* so a re-run with no contact changes produces nothing, and a managed update
|
||||||
|
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun diffManagedEvents(
|
||||||
|
desired: List<ManagedEventDesired>,
|
||||||
|
existing: List<ManagedEventRow>,
|
||||||
|
): ManagedDiff {
|
||||||
|
val desiredByUid = desired.associateBy { it.uid }
|
||||||
|
val existingByUid = existing.associateBy { it.uid }
|
||||||
|
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||||
|
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||||
|
val updates = buildList {
|
||||||
|
for (d in desired) {
|
||||||
|
val row = existingByUid[d.uid] ?: continue
|
||||||
|
val columns = buildMap<String, Any?> {
|
||||||
|
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||||
|
if (row.dtStartMillis != d.dtStartMillis) {
|
||||||
|
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||||
|
}
|
||||||
|
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||||
|
}
|
||||||
|
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderAlertStore
|
): ReminderAlertStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
|
): ContactSpecialDatesDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
|
): SpecialDatesCalendarSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
@@ -513,6 +514,95 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact special dates (issue #15) ------------------------------
|
||||||
|
|
||||||
|
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||||
|
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date types the user wants mirrored (default: all three). */
|
||||||
|
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed calendar id per type, for the ones that currently exist. */
|
||||||
|
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.mapNotNull { type ->
|
||||||
|
prefs[calendarIdKey(type)]?.let { type to it }
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||||
|
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title template per type ("{name}'s birthday"); an empty string means
|
||||||
|
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||||
|
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||||
|
* birth year is unknown or [specialDatesShowAge] is off).
|
||||||
|
*/
|
||||||
|
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
store.edit { it[titleTemplateKey(type)] = template }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||||
|
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last mirror sync, for the settings status line. */
|
||||||
|
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||||
|
SpecialDatesStatus(
|
||||||
|
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||||
|
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||||
|
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||||
|
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||||
|
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||||
|
val specialDatesLastForegroundSync: Flow<Long> =
|
||||||
|
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||||
|
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeEnabledKey(type: SpecialDateType) =
|
||||||
|
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||||
|
|
||||||
|
private fun calendarIdKey(type: SpecialDateType) =
|
||||||
|
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||||
|
|
||||||
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -605,9 +695,30 @@ class SettingsPrefs @Inject constructor(
|
|||||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||||
const val MIN_BACKUP_INTERVAL = 30L
|
const val MIN_BACKUP_INTERVAL = 30L
|
||||||
|
|
||||||
|
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||||
|
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||||
|
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||||
|
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||||
|
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||||
|
longPreferencesKey("special_dates_last_foreground_sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||||
|
data class SpecialDatesStatus(
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||||
|
val stalled: SpecialDatesStalledReason?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Why the special-dates mirror is paused. */
|
||||||
|
enum class SpecialDatesStalledReason {
|
||||||
|
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||||
|
PermissionRevoked,
|
||||||
|
}
|
||||||
|
|
||||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
data class BackupStatus(
|
data class BackupStatus(
|
||||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
@@ -626,6 +737,18 @@ sealed interface CalendarReminderOverride {
|
|||||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored override for [calendarId] in an override map, as a
|
||||||
|
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||||
|
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||||
|
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||||
|
*/
|
||||||
|
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
|
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||||
|
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead times to prefill on a new event: the matching per-calendar override
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ data class CalendarSource(
|
|||||||
* owns for its own calendars). Always null for synced calendars.
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
*/
|
*/
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||||
|
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||||
|
* so it's hidden from the new-event calendar picker and its events lock those
|
||||||
|
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||||
|
* even after a backup restore clears the app's stored ids.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||||
|
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||||
|
* visibility and reminder defaults for free. See
|
||||||
|
* docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
enum class SpecialDateType {
|
||||||
|
Birthday,
|
||||||
|
Anniversary,
|
||||||
|
|
||||||
|
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||||
|
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||||
|
* mirror without duplicating; [year] is null when the contact stored the date
|
||||||
|
* without one (`--MM-dd`), in which case age can't be shown.
|
||||||
|
*/
|
||||||
|
data class ContactSpecialDate(
|
||||||
|
val lookupKey: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: SpecialDateType,
|
||||||
|
val month: Int,
|
||||||
|
val day: Int,
|
||||||
|
val year: Int?,
|
||||||
|
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The parsed month/day (+ optional year) of a contact date. */
|
||||||
|
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||||
|
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||||
|
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||||
|
*/
|
||||||
|
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||||
|
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||||
|
* seen in the wild:
|
||||||
|
* - full `yyyy-MM-dd` (year known),
|
||||||
|
* - year-less `--MM-dd` (year null),
|
||||||
|
* - compact `yyyyMMdd`.
|
||||||
|
*
|
||||||
|
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||||
|
* it's unit-tested without a device.
|
||||||
|
*/
|
||||||
|
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||||
|
val s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
|
||||||
|
// Year-less: "--MM-dd" or "--MMdd".
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||||
|
return validated(null, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.contains('-')) {
|
||||||
|
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||||
|
return when (parts.size) {
|
||||||
|
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||||
|
// first part, so require the first token to look like a year.
|
||||||
|
3 -> if (s.startsWith('-')) null else validated(
|
||||||
|
year = parts[0].toIntOrNull() ?: return null,
|
||||||
|
month = parts[1].toIntOrNull() ?: return null,
|
||||||
|
day = parts[2].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
// Year-less "MM-dd" without the "--" prefix.
|
||||||
|
2 -> validated(
|
||||||
|
year = null,
|
||||||
|
month = parts[0].toIntOrNull() ?: return null,
|
||||||
|
day = parts[1].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact "yyyyMMdd".
|
||||||
|
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||||
|
return validated(
|
||||||
|
year = s.substring(0, 4).toInt(),
|
||||||
|
month = s.substring(4, 6).toInt(),
|
||||||
|
day = s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MM-dd" or compact "MMdd". */
|
||||||
|
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||||
|
if (rest.contains('-')) {
|
||||||
|
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||||
|
if (p.size != 2) return null
|
||||||
|
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||||
|
}
|
||||||
|
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||||
|
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm month/day form a real calendar date (validated against the actual
|
||||||
|
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||||
|
*/
|
||||||
|
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||||
|
if (month !in 1..12 || day !in 1..31) return null
|
||||||
|
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||||
|
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||||
|
.map { ContactDateParts(year, month, day) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||||
|
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||||
|
* only ever reconciles — and never deletes — events it actually owns.
|
||||||
|
*/
|
||||||
|
const val MANAGED_UID_PREFIX = "contact-"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||||
|
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||||
|
* type so the same contact's birthday and anniversary never collide, and — when
|
||||||
|
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||||
|
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||||
|
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||||
|
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||||
|
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||||
|
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||||
|
* dates on one contact don't collapse into a single event.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.managedUid(): String =
|
||||||
|
managedEventUid(type, lookupKey, customDiscriminator())
|
||||||
|
|
||||||
|
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||||
|
if (type == SpecialDateType.Custom) {
|
||||||
|
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
* date when the year is known (so age can be derived), otherwise the month/day
|
||||||
|
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||||
|
* anchor never drifts between syncs.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||||
|
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||||
|
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||||
|
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||||
|
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||||
|
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||||
|
* "Jane's birthday" when no year is known.
|
||||||
|
*/
|
||||||
|
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||||
|
template
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{year}", year?.toString().orEmpty())
|
||||||
|
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||||
|
.replace(Regex("""\(\s*\)"""), "")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim()
|
||||||
@@ -72,6 +72,8 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -176,6 +178,16 @@ fun CalendarHost(
|
|||||||
onImportConsumed()
|
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
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
@@ -272,6 +284,7 @@ fun CalendarHost(
|
|||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
@@ -303,6 +316,7 @@ fun CalendarHost(
|
|||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -33,6 +35,8 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -49,6 +53,12 @@ fun RootScreen(
|
|||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.READ_CALENDAR
|
context, Manifest.permission.READ_CALENDAR
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
// Refresh the contact special-dates mirror on foreground (the
|
||||||
|
// worker is debounced and no-ops when the feature is off). Gated
|
||||||
|
// on the permission so users without the opt-in never enqueue it.
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
lifecycle.addObserver(obs)
|
||||||
@@ -76,6 +86,8 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
|
|||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
@@ -191,6 +192,7 @@ fun AgendaScreen(
|
|||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -289,6 +291,7 @@ private fun AgendaContent(
|
|||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -318,6 +321,7 @@ private fun AgendaContent(
|
|||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -333,6 +337,7 @@ private fun AgendaList(
|
|||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -342,7 +347,7 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -362,10 +367,16 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
private fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(date, today),
|
text = agendaDayLabel(date, today),
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
@@ -142,7 +142,7 @@ fun CalendarsScreen(
|
|||||||
sessionKey = editorSession,
|
sessionKey = editorSession,
|
||||||
isNew = editorId == NEW_CALENDAR_ID,
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
@@ -507,7 +507,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = color,
|
selected = color,
|
||||||
onSelect = { color = it },
|
onSelect = { color = it },
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
|||||||
@@ -67,16 +67,3 @@ fun ColorSwatchRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
|
||||||
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
|||||||
onImeAction: (() -> Unit)? = null,
|
onImeAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = when {
|
||||||
textStyle.color
|
// A disabled field reads as dimmed, like a locked value.
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.onSurface
|
textStyle.color.isSpecified -> textStyle.color
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
textStyle = resolvedStyle,
|
textStyle = resolvedStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
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.TimePicker
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
|||||||
onConfirm: (LocalTime) -> Unit,
|
onConfirm: (LocalTime) -> Unit,
|
||||||
onDismiss: () -> 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(
|
val state = rememberTimePickerState(
|
||||||
initialHour = initial.hour,
|
initialHour = initial.hour,
|
||||||
initialMinute = initial.minute,
|
initialMinute = initial.minute,
|
||||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
is24Hour = LocalUse24HourFormat.current,
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
|||||||
text = { TimePicker(state = state) },
|
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Managed special-dates events have no delete — the sync
|
||||||
|
// would resurrect them; the date is removed at the contact.
|
||||||
|
if (!s.isManaged) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
enabled = deleteState != DeleteUiState.Deleting,
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
@@ -260,6 +263,7 @@ fun EventDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
|||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
val canModify: Boolean = false,
|
val canModify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event belongs to a managed special-dates calendar: its editable
|
||||||
|
* fields (reminders, notes) can still be edited, but delete is hidden —
|
||||||
|
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
detail = corrected,
|
detail = corrected,
|
||||||
calendarName = calendar?.displayName,
|
calendarName = calendar?.displayName,
|
||||||
canModify = calendar?.canModifyContents == true,
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
isManaged = calendar?.isManaged == true,
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ import de.jeanlucmakiola.calendula.domain.toRRule
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
@@ -439,6 +439,10 @@ private fun EventEditContent(
|
|||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
|
// user's to edit.
|
||||||
|
val locked = state.isManaged
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -523,6 +527,7 @@ private fun EventEditContent(
|
|||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
enabled = !locked,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
@@ -535,6 +540,17 @@ private fun EventEditContent(
|
|||||||
.height(3.dp)
|
.height(3.dp)
|
||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
if (locked) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_edit_managed_hint,
|
||||||
|
selectedCalendar?.displayName.orEmpty(),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
@@ -553,7 +569,11 @@ private fun EventEditContent(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
Switch(
|
||||||
|
checked = form.isAllDay,
|
||||||
|
onCheckedChange = viewModel::setAllDay,
|
||||||
|
enabled = !locked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -565,6 +585,7 @@ private fun EventEditContent(
|
|||||||
locale = locale,
|
locale = locale,
|
||||||
onDateClick = { picker = PickerTarget.StartDate },
|
onDateClick = { picker = PickerTarget.StartDate },
|
||||||
onTimeClick = { picker = PickerTarget.StartTime },
|
onTimeClick = { picker = PickerTarget.StartTime },
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
ScheduleRow(
|
ScheduleRow(
|
||||||
label = stringResource(R.string.event_edit_ends),
|
label = stringResource(R.string.event_edit_ends),
|
||||||
@@ -574,6 +595,7 @@ private fun EventEditContent(
|
|||||||
onDateClick = { picker = PickerTarget.EndDate },
|
onDateClick = { picker = PickerTarget.EndDate },
|
||||||
onTimeClick = { picker = PickerTarget.EndTime },
|
onTimeClick = { picker = PickerTarget.EndTime },
|
||||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
@@ -704,7 +726,7 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Repeat,
|
icon = Icons.Default.Repeat,
|
||||||
iconContentDescription = null,
|
iconContentDescription = null,
|
||||||
onClick = { showRecurrencePicker = true },
|
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1727,7 +1749,7 @@ private fun ColorPickerDialog(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onSelect = onPickRaw,
|
onSelect = onPickRaw,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -1862,6 +1884,7 @@ private fun InlineField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1875,6 +1898,7 @@ private fun InlineField(
|
|||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1889,6 +1913,7 @@ private fun ScheduleRow(
|
|||||||
onDateClick: () -> Unit,
|
onDateClick: () -> Unit,
|
||||||
onTimeClick: () -> Unit,
|
onTimeClick: () -> Unit,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -1898,11 +1923,12 @@ private fun ScheduleRow(
|
|||||||
timeOfDayFormatter(use24Hour, locale)
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||||
val valueColor = if (isError) {
|
// row dims to a plain, non-tappable value.
|
||||||
MaterialTheme.colorScheme.error
|
val valueColor = when {
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.primary
|
isError -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1919,7 +1945,7 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onDateClick)
|
.clickable(enabled = enabled, onClick = onDateClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
if (!isAllDay) {
|
if (!isAllDay) {
|
||||||
@@ -1928,7 +1954,7 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onTimeClick)
|
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True when the event's calendar is a contact special-dates calendar the app
|
||||||
|
* manages. While editing such an event the title, date and recurrence are
|
||||||
|
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
|||||||
@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val autofocusTitle: Boolean,
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Every calendar the provider exposes; the source for both lists below. */
|
||||||
|
private val allCalendars: Flow<List<CalendarSource>> =
|
||||||
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
* excluded, so you can't create into a calendar you've removed from the app;
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
|
* Managed special-dates calendars are excluded too: their events are owned by
|
||||||
|
* the contact sync, which would delete any user event created there.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
repository.calendars(),
|
allCalendars,
|
||||||
prefs.disabledCalendarIds,
|
prefs.disabledCalendarIds,
|
||||||
) { calendars, disabled ->
|
) { calendars, disabled ->
|
||||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||||
}.catch { emit(emptyList()) }
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
@@ -165,21 +171,33 @@ class EventEditViewModel @Inject constructor(
|
|||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
) { local, external, palette ->
|
allCalendars,
|
||||||
|
) { local, external, palette, allCalendars ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
?: external.writable.firstOrNull()?.id
|
?: external.writable.firstOrNull()?.id
|
||||||
val resolved = form.copy(calendarId = resolvedId)
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
|
||||||
|
// Editing an event in a managed calendar locks its synced fields. Keyed
|
||||||
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
|
// backup restore too.
|
||||||
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
|
// The picker offers writable calendars only; when editing a managed event
|
||||||
|
// its own (excluded) calendar is added back so the row still names it.
|
||||||
|
val pickerCalendars =
|
||||||
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = external.writable,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
@@ -417,7 +435,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target != null && target.original.rrule != null) {
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
|
// which would split the series into an exception the sync then reverts.
|
||||||
|
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -91,13 +93,16 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
@@ -111,6 +116,7 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||||
@@ -139,7 +145,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||||
@@ -203,7 +209,19 @@ fun SettingsScreen(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
NotificationsScreen(
|
||||||
|
state = state,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBack = { section = null },
|
||||||
|
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.SpecialDates,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +275,13 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||||
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguageRow(position = Position.Middle)
|
||||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
// is API 33+; on older versions the tile is still addable manually from
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
@@ -985,6 +1010,7 @@ private fun NotificationsScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenSpecialDates: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
@@ -1109,6 +1135,25 @@ private fun NotificationsScreen(
|
|||||||
Column {
|
Column {
|
||||||
state.writableCalendars.forEach { calendar ->
|
state.writableCalendars.forEach { calendar ->
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
// A contact special-dates calendar owns its reminders in
|
||||||
|
// its own section — link there instead of an override.
|
||||||
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onOpenSpecialDates,
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
// Calendar card; tapping expands it into a grouped list
|
// Calendar card; tapping expands it into a grouped list
|
||||||
// of three (the card + the timed and all-day rows).
|
// of three (the card + the timed and all-day rows).
|
||||||
@@ -1231,6 +1276,287 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contact special dates (issue #15)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||||
|
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||||
|
// banner after the permission was re-granted).
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val requestOrEnable: () -> Unit = {
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
viewModel.setSpecialDatesEnabled(true)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmDisableAll by remember { mutableStateOf(false) }
|
||||||
|
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
// Paused banner: the permission was revoked after enabling.
|
||||||
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||||
|
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_enable),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.enabled,
|
||||||
|
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.enabled) {
|
||||||
|
// Per-type card: the toggle, and while on, its title format and its
|
||||||
|
// calendar-wide reminder.
|
||||||
|
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||||
|
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
||||||
|
val on = type in state.types
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(specialDateTypeLabel(type)),
|
||||||
|
position = if (on) Position.Top else Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = on,
|
||||||
|
onCheckedChange = { want ->
|
||||||
|
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
else confirmDisableType = type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (on) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_template),
|
||||||
|
summary = state.titleTemplates[type].orEmpty(),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { editTemplate = type },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { reminderPickerType = type },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_show_year),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showYear,
|
||||||
|
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||||
|
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = viewModel::syncSpecialDatesNow,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDisableAll) {
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||||
|
onDismiss = { confirmDisableAll = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
confirmDisableType?.let { type ->
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(
|
||||||
|
R.string.settings_special_dates_disable_type_message,
|
||||||
|
stringResource(specialDateTypeLabel(type)),
|
||||||
|
),
|
||||||
|
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||||
|
onDismiss = { confirmDisableType = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editTemplate?.let { type ->
|
||||||
|
SpecialDatesTemplateDialog(
|
||||||
|
initial = state.titleTemplates[type].orEmpty(),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||||
|
onDismiss = { editTemplate = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reminderPickerType?.let { type ->
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
||||||
|
// Managed calendars own their reminders outright — no "inherit global".
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||||
|
onDismiss = { reminderPickerType = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||||
|
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
||||||
|
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesDisableDialog(
|
||||||
|
message: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||||
|
text = { Text(message) },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesTemplateDialog(
|
||||||
|
initial: String,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var text by rememberSaveable { mutableStateOf(initial) }
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
// The app's borderless input over a tonal surface (the dialog
|
||||||
|
// convention — see DialogControls), not Material's outlined field.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
placeholder = stringResource(R.string.settings_special_dates_template),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { onConfirm(text) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
) { Text(stringResource(R.string.dialog_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||||
|
if (lastRun <= 0L) {
|
||||||
|
stringResource(R.string.settings_special_dates_never_synced)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_special_dates_last_synced,
|
||||||
|
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||||
|
lastRun,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
@@ -1314,22 +1640,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
|||||||
return DateFormat.getTimeFormat(context).format(time)
|
return DateFormat.getTimeFormat(context).format(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
|
||||||
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
|
||||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
|
||||||
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
|
||||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||||
// than a (non-composable) joinToString transform.
|
|
||||||
val parts = ArrayList<String>(minutes.size)
|
|
||||||
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
|
||||||
return parts.joinToString(", ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
@@ -7,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -75,4 +77,30 @@ data class SettingsUiState(
|
|||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
*/
|
*/
|
||||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Ids of the contact special-dates calendars. Their reminders are owned by
|
||||||
|
* the Contact special dates section, so the per-calendar override list links
|
||||||
|
* out to it instead of offering an override here.
|
||||||
|
*/
|
||||||
|
val managedCalendarIds: Set<Long> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||||
|
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||||
|
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||||
|
* localized default so the editor never shows an empty field.
|
||||||
|
*/
|
||||||
|
data class SpecialDatesUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||||
|
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||||
|
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||||
|
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||||
|
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||||
|
val showYear: Boolean = true,
|
||||||
|
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||||
|
val stalledPermission: Boolean = false,
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,17 +11,24 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -52,6 +59,8 @@ import javax.inject.Inject
|
|||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -98,8 +107,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
) { overrides, allDayOverrides, calendars ->
|
prefs.managedCalendarIds,
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
@@ -144,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
|
managedCalendarIds = overrides.managedIds,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -192,6 +203,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val timed: Map<Long, List<Int>>,
|
val timed: Map<Long, List<Int>>,
|
||||||
val allDay: Map<Long, List<Int>>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
|
val managedIds: Set<Long>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -215,6 +227,95 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
|
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||||
|
combine(
|
||||||
|
prefs.specialDatesEnabled,
|
||||||
|
prefs.specialDatesTypes,
|
||||||
|
prefs.specialDatesTitleTemplates,
|
||||||
|
prefs.specialDatesShowYear,
|
||||||
|
prefs.specialDatesStatus,
|
||||||
|
) { enabled, types, templates, showYear, status ->
|
||||||
|
SpecialDatesUiState(
|
||||||
|
enabled = enabled,
|
||||||
|
types = types,
|
||||||
|
// A blank stored template resolves to the localized default so the
|
||||||
|
// editor always shows something sensible.
|
||||||
|
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||||
|
specialDatesSpec.resolveTitleTemplate(type, templates)
|
||||||
|
},
|
||||||
|
showYear = showYear,
|
||||||
|
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||||
|
lastRun = status.lastRun,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.specialDatesCalendars,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { base, calendars, allDayOverrides ->
|
||||||
|
base.copy(
|
||||||
|
// An override is always seeded on creation; present-empty = None.
|
||||||
|
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||||
|
allDayOverrides.choiceFor(calendarId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SpecialDatesUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||||
|
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||||
|
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||||
|
* before enabling.
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesEnabled(enabled)
|
||||||
|
if (enabled) {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
} else {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||||
|
withContext(io) { specialDatesEngine.teardown() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||||
|
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesShowYear(enabled)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a type's managed-calendar reminder default and apply it to all its
|
||||||
|
* existing events (managed calendars own their reminders calendar-wide).
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||||
|
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
|||||||
fun WeekScreen(
|
fun WeekScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
@@ -249,6 +250,7 @@ fun WeekScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
@@ -268,6 +270,7 @@ private fun WeekContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -342,6 +345,7 @@ private fun WeekContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -355,6 +359,7 @@ private fun WeekSuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -363,7 +368,7 @@ private fun WeekSuccess(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(topSectionColor),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today)
|
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
}
|
}
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
@@ -427,7 +432,11 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
private fun WeekDayHeader(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val weekStart = days.first()
|
val weekStart = days.first()
|
||||||
val weekNumber = remember(weekStart) {
|
val weekNumber = remember(weekStart) {
|
||||||
@@ -453,7 +462,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
|||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
<!-- Permission-Flow (F1) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
<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_request_button">Kalender-Zugriff erlauben</string>
|
||||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</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>
|
<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">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- 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_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</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_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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,464 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Cargando…</string>
|
||||||
|
<string name="state_retry">Vuelve a intentarlo</string>
|
||||||
|
<string name="state_failure_unknown">Algo salio mal.</string>
|
||||||
|
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Permite el acceso</string>
|
||||||
|
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
|
||||||
|
<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á 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>
|
||||||
|
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
|
||||||
|
<string name="permission_retry_button">Intentarlo de nuevo</string>
|
||||||
|
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
|
||||||
|
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
|
||||||
|
<string name="month_prev">Mes anterior</string>
|
||||||
|
<string name="month_next">Siguiente mes</string>
|
||||||
|
<string name="month_today_action">Hoy</string>
|
||||||
|
<string name="month_more_actions">Mas acciones</string>
|
||||||
|
<string name="month_open_menu">Abrir menu</string>
|
||||||
|
<string name="month_action_settings">Ajustes</string>
|
||||||
|
<string name="month_a11y_today_prefix">Hoy</string>
|
||||||
|
<string name="week_today_action">Esta semana</string>
|
||||||
|
<string name="week_number_label">Sem</string>
|
||||||
|
<string name="day_today_action">Hoy</string>
|
||||||
|
<string name="event_detail_back">Atras</string>
|
||||||
|
<string name="event_detail_edit">Editar</string>
|
||||||
|
<string name="event_detail_delete">Eliminar</string>
|
||||||
|
<string name="event_detail_share">Compartir</string>
|
||||||
|
<string name="event_share_chooser_title">Compartir evento</string>
|
||||||
|
<string name="event_share_failed">No se pudo compartir el evento.</string>
|
||||||
|
<string name="event_delete_title">¿Eliminar evento?</string>
|
||||||
|
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
|
||||||
|
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo este evento</string>
|
||||||
|
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
|
||||||
|
<string name="event_delete_option_series">Todos los eventos en la serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Editar evento recurrente</string>
|
||||||
|
<string name="event_delete_failed">No se pudo eliminar el evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="dialog_ok">Okey</string>
|
||||||
|
<string name="event_edit_new_title">Nuevo evento</string>
|
||||||
|
<string name="event_edit_close">Cerrar</string>
|
||||||
|
<string name="event_edit_save">Guardar</string>
|
||||||
|
<string name="event_edit_title_hint">Añadir título</string>
|
||||||
|
<string name="event_edit_starts">Inicio</string>
|
||||||
|
<string name="event_edit_ends">Final</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
|
||||||
|
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
|
||||||
|
<string name="event_edit_more_fields">Mas campos</string>
|
||||||
|
<string name="event_edit_add">Añadir</string>
|
||||||
|
<string name="event_edit_add_reminder">Añadir recordatorio</string>
|
||||||
|
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
|
||||||
|
<string name="event_edit_attendees">Invitados</string>
|
||||||
|
<string name="event_edit_add_guest">Añadir invitados</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
|
||||||
|
<string name="event_edit_remove_guest">Eliminar invitado</string>
|
||||||
|
<string name="event_edit_attendee_required">Obligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_unit_minutes">minutos</string>
|
||||||
|
<string name="reminder_unit_hours">horas</string>
|
||||||
|
<string name="reminder_unit_days">días</string>
|
||||||
|
<string name="reminder_unit_weeks">semanas</string>
|
||||||
|
<string name="event_edit_availability">Disponibilidad</string>
|
||||||
|
<string name="event_edit_visibility">Visibilidad</string>
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Color del calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Color personalizado</string>
|
||||||
|
<string name="event_edit_color_reset">Reiniciar</string>
|
||||||
|
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
|
||||||
|
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
|
||||||
|
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
|
||||||
|
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||||
|
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
|
||||||
|
<string name="event_edit_recurrence_none">No se repite</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||||
|
<string name="event_edit_recurrence_every">Cada</string>
|
||||||
|
<string name="recurrence_unit_days">días</string>
|
||||||
|
<string name="recurrence_unit_weeks">semanas</string>
|
||||||
|
<string name="recurrence_unit_months">meses</string>
|
||||||
|
<string name="recurrence_unit_years">años</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Finaliza</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">En una fecha</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
|
||||||
|
<string name="event_edit_recurrence_times">veces</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
|
||||||
|
<string name="event_availability_busy">Ocupado</string>
|
||||||
|
<string name="event_access_default">Por defecto</string>
|
||||||
|
<string name="event_access_public">Público</string>
|
||||||
|
<string name="event_detail_all_day">Todo el día</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
|
||||||
|
<string name="event_detail_location">Ubicación</string>
|
||||||
|
<string name="event_detail_description">Descripción</string>
|
||||||
|
<string name="event_detail_attendees">Participantes</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrencia</string>
|
||||||
|
<string name="agenda_today_action">Hoy</string>
|
||||||
|
<string name="agenda_header_today">Hoy</string>
|
||||||
|
<string name="search_back">Atras</string>
|
||||||
|
<string name="widget_new_event">Nuevo evento</string>
|
||||||
|
<string name="widget_prev_month">Mes anterior</string>
|
||||||
|
<string name="widget_next_month">Siguiente mes</string>
|
||||||
|
<string name="widget_today">Hoy</string>
|
||||||
|
<string name="filter_title">Calendarios</string>
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_back">Atras</string>
|
||||||
|
<string name="agenda_range_day">Hoy</string>
|
||||||
|
<string name="agenda_range_this_week">Esta semana</string>
|
||||||
|
<string name="settings_section_calendars">Calendarios</string>
|
||||||
|
<string name="calendars_title">Calendarios</string>
|
||||||
|
<string name="calendars_color_label">Color</string>
|
||||||
|
<string name="import_close">Cerrar</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuevo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuevo evento</string>
|
||||||
|
<string name="event_detail_recurring">Repetir el evento</string>
|
||||||
|
<string name="recurrence_daily">Todos los días</string>
|
||||||
|
<string name="recurrence_weekly">Todas las semanas</string>
|
||||||
|
<string name="recurrence_monthly">Todos los meses</string>
|
||||||
|
<string name="recurrence_yearly">Todos los años</string>
|
||||||
|
<string name="recurrence_every_n_days">Cada %1$d días</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
|
||||||
|
<string name="recurrence_every_n_months">Cada %1$d meses</string>
|
||||||
|
<string name="recurrence_every_n_years">Cada %1$d años</string>
|
||||||
|
<string name="event_detail_not_found">Este evento ya no existe.</string>
|
||||||
|
<string name="event_attendee_accepted">Aceptado</string>
|
||||||
|
<string name="event_attendee_declined">Rechazado</string>
|
||||||
|
<string name="event_attendee_tentative">Tentativo</string>
|
||||||
|
<string name="event_attendee_needs_action">Sin respuesta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Recordatorios</string>
|
||||||
|
<string name="event_detail_timezone">Zona horaria</string>
|
||||||
|
<string name="event_status_tentative">Tentativo</string>
|
||||||
|
<string name="event_status_cancelled">Cancelado</string>
|
||||||
|
<string name="event_availability_free">Disponible</string>
|
||||||
|
<string name="event_access_private">Privado</string>
|
||||||
|
<string name="event_access_confidential">Confidencial</string>
|
||||||
|
<string name="event_attendee_organizer">Organizador</string>
|
||||||
|
<string name="event_attendee_optional">Opcional</string>
|
||||||
|
<string name="agenda_range_custom">Personalizado…</string>
|
||||||
|
<string name="calendars_backup_header">Respaldo</string>
|
||||||
|
<string name="backup_channel_name">Respaldo</string>
|
||||||
|
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Al la hora del evento</string>
|
||||||
|
<string name="reminder_default">Recordatorio por defecto</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto antes</item>
|
||||||
|
<item quantity="many">%d minutos antes</item>
|
||||||
|
<item quantity="other">%d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hora antes</item>
|
||||||
|
<item quantity="many">%d horas antes</item>
|
||||||
|
<item quantity="other">%d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d día antes</item>
|
||||||
|
<item quantity="many">%d días antes</item>
|
||||||
|
<item quantity="other">%d días antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d semana antes</item>
|
||||||
|
<item quantity="many">%d semanas antes</item>
|
||||||
|
<item quantity="other">%d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minutos</item>
|
||||||
|
<item quantity="other">%d minutos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hora</item>
|
||||||
|
<item quantity="many">%d horas</item>
|
||||||
|
<item quantity="other">%d horas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d días</item>
|
||||||
|
<item quantity="other">%d días</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d semana</item>
|
||||||
|
<item quantity="many">%d semanas</item>
|
||||||
|
<item quantity="other">%d semanas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Sin título)</string>
|
||||||
|
<string name="reminder_channel_name">Recordatorios del evento</string>
|
||||||
|
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
|
||||||
|
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Mas tarde</string>
|
||||||
|
<string name="reminder_action_snooze">Posponer</string>
|
||||||
|
<string name="reminder_action_dismiss">Descartar</string>
|
||||||
|
<string name="view_month">Mes</string>
|
||||||
|
<string name="view_week">Semana</string>
|
||||||
|
<string name="view_day">Día</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Ir a la fecha</string>
|
||||||
|
<string name="agenda_header_tomorrow">Mañana</string>
|
||||||
|
<string name="agenda_empty_title">Ya estas al día</string>
|
||||||
|
<string name="search_action">Buscar</string>
|
||||||
|
<string name="search_hint">Buscar eventos</string>
|
||||||
|
<string name="search_clear">Limpiar</string>
|
||||||
|
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
|
||||||
|
<string name="search_empty">No evento coincide con “%1$s”.</string>
|
||||||
|
<string name="widget_agenda_title">Próximamente</string>
|
||||||
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula mes</string>
|
||||||
|
<string name="widget_refresh">Actualizar</string>
|
||||||
|
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
|
||||||
|
<string name="settings_section_appearance">Apariencia</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Igual que el sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Oscuro</string>
|
||||||
|
<string name="settings_default_view">Vista por defecto</string>
|
||||||
|
<string name="settings_dynamic_color">Color dinámico</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
|
||||||
|
<string name="settings_week_start">La semana empieza en</string>
|
||||||
|
<string name="settings_week_start_auto">Automático</string>
|
||||||
|
<string name="settings_time_format">Formato de la hora</string>
|
||||||
|
<string name="settings_time_format_auto">Automático</string>
|
||||||
|
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-horas (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Lineas entre horas</string>
|
||||||
|
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
|
||||||
|
<string name="settings_dim_completed">Oscurecer eventos completados</string>
|
||||||
|
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
|
||||||
|
<string name="settings_past_events">Eventos pasados</string>
|
||||||
|
<string name="settings_past_events_show">Mostrar</string>
|
||||||
|
<string name="settings_past_events_dim">Oscurecer</string>
|
||||||
|
<string name="settings_past_events_hide">Ocultar</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Rango de agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra de tiempo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
|
||||||
|
<string name="agenda_range_this_month">Este mes</string>
|
||||||
|
<string name="agenda_range_week">Próximos 7 días</string>
|
||||||
|
<string name="agenda_range_month">Próximos 30 días</string>
|
||||||
|
<string name="agenda_range_custom_hint">Días</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d dias</item>
|
||||||
|
<item quantity="other">%d dias</item>
|
||||||
|
</plurals>
|
||||||
|
<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>
|
||||||
|
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
|
||||||
|
<string name="settings_section_notifications">Notificaciones</string>
|
||||||
|
<string name="settings_reminders">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
|
||||||
|
<string name="settings_default_reminder">Recordatorio por defecto</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
|
||||||
|
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
|
||||||
|
<string name="reminder_none">Ninguno</string>
|
||||||
|
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||||
|
<string name="reminder_custom_amount">Cantidad</string>
|
||||||
|
<string name="reminder_custom_set">Guardar</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notificaciones confiables</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
|
||||||
|
<string name="settings_snooze_duration">Duración al posponer</string>
|
||||||
|
<string name="settings_manage_calendars">Administrar calendarios</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
|
||||||
|
<string name="settings_section_language">Idioma</string>
|
||||||
|
<string name="settings_language">Idioma de la aplicación</string>
|
||||||
|
<string name="settings_language_auto">Igual que el sistema</string>
|
||||||
|
<string name="settings_translate">Ayuda a traducir</string>
|
||||||
|
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
|
||||||
|
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_license">Licencia</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">Por Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Fuente</string>
|
||||||
|
<string name="settings_about_support">Ayuda al desarrollo</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
|
||||||
|
<string name="settings_report_problem">Reportar un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
|
||||||
|
<string name="calendars_local_header">Tus calendarios</string>
|
||||||
|
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||||
|
<string name="calendars_add">Añadir calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||||
|
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||||
|
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Habilitar todos</string>
|
||||||
|
<string name="calendars_disable_all">Desactivar todos</string>
|
||||||
|
<string name="calendars_add_account">Añadir cuenta</string>
|
||||||
|
<string name="calendars_new_title">Nuevo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Editar calendario</string>
|
||||||
|
<string name="calendars_name_label">Nombre</string>
|
||||||
|
<string name="calendars_description_hint">Añadir descripción</string>
|
||||||
|
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
|
||||||
|
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Exportar como archivo .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Respaldo automático</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Cada %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
|
||||||
|
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
|
||||||
|
<string name="backup_failed_title">El respaldo automático fallo</string>
|
||||||
|
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
|
||||||
|
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Se exporto %d evento.</item>
|
||||||
|
<item quantity="many">Se exportaron %d eventos.</item>
|
||||||
|
<item quantity="other">Se exportaron %d eventos.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importar eventos</string>
|
||||||
|
<string name="import_target_header">Añadir a calendario</string>
|
||||||
|
<string name="import_empty">No se encontraron eventos en este archivo.</string>
|
||||||
|
<string name="import_failed">No se pudo leer el archivo.</string>
|
||||||
|
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
|
||||||
|
<string name="import_done_title">Importación completa</string>
|
||||||
|
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
|
||||||
|
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
|
||||||
|
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
|
||||||
|
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento en este archivo.</item>
|
||||||
|
<item quantity="many">%d eventos en este archivo.</item>
|
||||||
|
<item quantity="other">%d eventos en este archivo.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importar %d evento</item>
|
||||||
|
<item quantity="many">Importar %d eventos</item>
|
||||||
|
<item quantity="other">Importar %d eventos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d evento importado.</item>
|
||||||
|
<item quantity="many">%d eventos importados.</item>
|
||||||
|
<item quantity="other">%d eventos importados.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
|
||||||
|
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
||||||
|
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
|
||||||
|
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula ha fallado</string>
|
||||||
|
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||||
|
<string name="crash_dialog_report">Reportar</string>
|
||||||
|
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||||
|
<string name="crash_report_issue_title">Reporte de error</string>
|
||||||
|
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
|
||||||
|
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||||
|
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||||
|
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||||
|
<string name="event_attendee_resource">Recurso</string>
|
||||||
|
<string name="settings_section_about">Acerca de</string>
|
||||||
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,454 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Caricamento…</string>
|
||||||
|
<string name="state_retry">Riprova</string>
|
||||||
|
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
|
||||||
|
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Concedi l\'accesso</string>
|
||||||
|
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
||||||
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
||||||
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
|
<string name="permission_retry_button">Riprova</string>
|
||||||
|
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||||
|
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
|
<string name="month_prev">Mese precedente</string>
|
||||||
|
<string name="month_next">Mese prossimo</string>
|
||||||
|
<string name="month_today_action">Oggi</string>
|
||||||
|
<string name="month_more_actions">Altre azioni</string>
|
||||||
|
<string name="month_open_menu">Apri il menù</string>
|
||||||
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
|
<string name="week_today_action">Questa settimana</string>
|
||||||
|
<string name="week_number_label">Wk</string>
|
||||||
|
<string name="day_today_action">Oggi</string>
|
||||||
|
<string name="event_detail_back">Indietro</string>
|
||||||
|
<string name="event_detail_edit">Modifica</string>
|
||||||
|
<string name="event_detail_delete">Elimina</string>
|
||||||
|
<string name="event_detail_share">Condividi</string>
|
||||||
|
<string name="event_share_chooser_title">Condividi evento</string>
|
||||||
|
<string name="event_share_failed">Impossibile condividere questo evento.</string>
|
||||||
|
<string name="event_delete_title">Eliminare l\'evento?</string>
|
||||||
|
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
|
||||||
|
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo questo evento</string>
|
||||||
|
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
|
||||||
|
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
|
||||||
|
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
|
||||||
|
<string name="dialog_cancel">Annulla</string>
|
||||||
|
<string name="dialog_ok">Ok</string>
|
||||||
|
<string name="event_edit_new_title">Nuovo evento</string>
|
||||||
|
<string name="event_edit_close">Chiudi</string>
|
||||||
|
<string name="event_edit_save">Salva</string>
|
||||||
|
<string name="event_edit_title_hint">Aggiungi titolo</string>
|
||||||
|
<string name="event_edit_starts">Inizio</string>
|
||||||
|
<string name="event_edit_ends">Fine</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
|
||||||
|
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
|
||||||
|
<string name="event_edit_more_fields">Altri campi</string>
|
||||||
|
<string name="event_edit_add">Aggiungi</string>
|
||||||
|
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
|
||||||
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
|
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
|
<string name="reminder_unit_hours">ore</string>
|
||||||
|
<string name="reminder_unit_days">giorni</string>
|
||||||
|
<string name="reminder_unit_weeks">settimane</string>
|
||||||
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
|
<string name="event_edit_color">Colore</string>
|
||||||
|
<string name="event_edit_color_default">Colore calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
|
||||||
|
<string name="event_edit_conflict_title">Evento modificato altrove</string>
|
||||||
|
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||||
|
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||||
|
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||||
|
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
|
<string name="event_edit_recurrence_every">La serie</string>
|
||||||
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
|
<string name="recurrence_unit_years">anni</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
||||||
|
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_availability_busy">Occupato</string>
|
||||||
|
<string name="event_access_default">Default</string>
|
||||||
|
<string name="event_access_public">Pubblico</string>
|
||||||
|
<string name="event_detail_all_day">Tutto il giorno</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
|
||||||
|
<string name="event_detail_location">Luogo</string>
|
||||||
|
<string name="event_detail_description">Descrizione</string>
|
||||||
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
|
<string name="recurrence_daily">Ogni giorni</string>
|
||||||
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
|
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
|
||||||
|
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
|
||||||
|
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
|
||||||
|
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
|
||||||
|
<string name="event_attendee_accepted">Accettato</string>
|
||||||
|
<string name="event_attendee_declined">Rifiutato</string>
|
||||||
|
<string name="event_attendee_tentative">Provvisorio</string>
|
||||||
|
<string name="event_attendee_needs_action">Nessuna risposta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Promemoria</string>
|
||||||
|
<string name="event_detail_timezone">Fuso orario</string>
|
||||||
|
<string name="event_status_tentative">Provvisorio</string>
|
||||||
|
<string name="event_status_cancelled">Annullato</string>
|
||||||
|
<string name="event_availability_free">Disponibile</string>
|
||||||
|
<string name="event_access_private">Privato</string>
|
||||||
|
<string name="event_access_confidential">Confidenziale</string>
|
||||||
|
<string name="event_attendee_organizer">Organizzatore</string>
|
||||||
|
<string name="event_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_detail_self_response">La tua risposta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">All\'ora dell\'evento</string>
|
||||||
|
<string name="reminder_default">Promemoria standard</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto prima</item>
|
||||||
|
<item quantity="many">%d minuti prima</item>
|
||||||
|
<item quantity="other">%d minuti prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d ora prima</item>
|
||||||
|
<item quantity="many">%d ore prima</item>
|
||||||
|
<item quantity="other">%d ore prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d giorno prima</item>
|
||||||
|
<item quantity="many">%d giorni prima</item>
|
||||||
|
<item quantity="other">%d giorni prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d settimana prima</item>
|
||||||
|
<item quantity="many">%d settimanw prima</item>
|
||||||
|
<item quantity="other">%d settimane prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minuti</item>
|
||||||
|
<item quantity="other">%d minuti</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d ora</item>
|
||||||
|
<item quantity="many">%d ore</item>
|
||||||
|
<item quantity="other">%d ore</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d settimana</item>
|
||||||
|
<item quantity="many">%d settimane</item>
|
||||||
|
<item quantity="other">%d settimane</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(nessun titolo)</string>
|
||||||
|
<string name="reminder_channel_name">Promemoria evento</string>
|
||||||
|
<string name="event_attendee_resource">Risorsa</string>
|
||||||
|
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
|
<string name="reminder_action_snooze">Posponi</string>
|
||||||
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
|
<string name="view_month">Mese</string>
|
||||||
|
<string name="view_week">Settimana</string>
|
||||||
|
<string name="view_day">Giorno</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Vai alla data</string>
|
||||||
|
<string name="agenda_today_action">Oggi</string>
|
||||||
|
<string name="agenda_header_tomorrow">Domani</string>
|
||||||
|
<string name="agenda_empty_title">Hai fatto tutto</string>
|
||||||
|
<string name="search_action">Cerca</string>
|
||||||
|
<string name="search_hint">Cerca eventi</string>
|
||||||
|
<string name="search_back">Indietro</string>
|
||||||
|
<string name="search_clear">Pulisci</string>
|
||||||
|
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
|
||||||
|
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
|
||||||
|
<string name="widget_agenda_title">Prossimamente</string>
|
||||||
|
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||||
|
<string name="widget_month_label">Mese Calendula</string>
|
||||||
|
<string name="widget_refresh">Aggiorna</string>
|
||||||
|
<string name="widget_new_event">Nuovo evento</string>
|
||||||
|
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||||
|
<string name="widget_prev_month">Mese precedente</string>
|
||||||
|
<string name="filter_title">Calendari</string>
|
||||||
|
<string name="settings_section_appearance">Aspetto</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Sistema</string>
|
||||||
|
<string name="settings_theme_light">Chiaro</string>
|
||||||
|
<string name="settings_theme_dark">Scuro</string>
|
||||||
|
<string name="settings_default_view">Vista di default</string>
|
||||||
|
<string name="settings_dynamic_color">Colori dinamici</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
|
||||||
|
<string name="settings_week_start">La settimana comincia di</string>
|
||||||
|
<string name="settings_week_start_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format">Formato ora</string>
|
||||||
|
<string name="settings_time_format_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format_12h">12 ore</string>
|
||||||
|
<string name="settings_time_format_24h">24 ore</string>
|
||||||
|
<string name="settings_hour_lines">Righe per le ore</string>
|
||||||
|
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||||
|
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||||
|
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||||
|
<string name="settings_past_events">Eventi passati</string>
|
||||||
|
<string name="settings_past_events_show">Mostra</string>
|
||||||
|
<string name="settings_past_events_dim">Attenua</string>
|
||||||
|
<string name="settings_past_events_hide">Nascondi</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||||
|
<string name="agenda_range_day">Oggi</string>
|
||||||
|
<string name="agenda_range_this_month">Questo mese</string>
|
||||||
|
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||||
|
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||||
|
<string name="agenda_range_custom">Personalizza…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Giorni</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||||
|
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||||
|
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||||
|
<string name="settings_section_notifications">Notifiche</string>
|
||||||
|
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||||
|
<string name="reminder_none">Nessuno</string>
|
||||||
|
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||||
|
<string name="reminder_custom_amount">Valore</string>
|
||||||
|
<string name="reminder_custom_set">Imposta</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||||
|
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||||
|
<string name="settings_section_calendars">Calendari</string>
|
||||||
|
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
|
||||||
|
<string name="settings_section_language">Lingua</string>
|
||||||
|
<string name="settings_language">Lingua dell\'app</string>
|
||||||
|
<string name="settings_language_auto">Lingua di sistema</string>
|
||||||
|
<string name="settings_translate">Aiuta a tradurre</string>
|
||||||
|
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||||
|
<string name="settings_section_about">A proposito</string>
|
||||||
|
<string name="settings_license">Licenza</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">da Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Sorgente</string>
|
||||||
|
<string name="settings_about_support">Sostieni lo sviluppo</string>
|
||||||
|
<string name="settings_about_version">Versione %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icona dell\'app</string>
|
||||||
|
<string name="settings_report_problem">Segnala un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||||
|
<string name="calendars_title">Calendari</string>
|
||||||
|
<string name="calendars_local_header">I tuoi calendari</string>
|
||||||
|
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||||
|
<string name="calendars_add">Aggiungi calendario</string>
|
||||||
|
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||||
|
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||||
|
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Attiva tutti</string>
|
||||||
|
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||||
|
<string name="calendars_add_account">Aggiungi account</string>
|
||||||
|
<string name="calendars_new_title">Nuovo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Modifica calendario</string>
|
||||||
|
<string name="calendars_name_label">Nome</string>
|
||||||
|
<string name="calendars_color_label">Colore</string>
|
||||||
|
<string name="calendars_description_hint">Aggiungi descrizione</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
|
||||||
|
<string name="calendars_backup_header">Backup</string>
|
||||||
|
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Esporta come file .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Backup automatico</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Cartella di backup</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervallo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Ogni %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Backup</string>
|
||||||
|
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||||
|
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||||
|
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||||
|
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Esportato %d evento.</item>
|
||||||
|
<item quantity="many">Esportati %d eventi.</item>
|
||||||
|
<item quantity="other">Esportati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importa eventi</string>
|
||||||
|
<string name="import_target_header">Aggiungi al calendario</string>
|
||||||
|
<string name="import_empty">Non ci sono eventi in questo file.</string>
|
||||||
|
<string name="import_failed">Impossibile leggere il file.</string>
|
||||||
|
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
|
||||||
|
<string name="import_done_title">Import completato</string>
|
||||||
|
<string name="import_close">Chiudi</string>
|
||||||
|
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
|
||||||
|
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
|
||||||
|
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
|
||||||
|
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento in questo file.</item>
|
||||||
|
<item quantity="many">%d eventi in questo file.</item>
|
||||||
|
<item quantity="other">%d eventi in questo file.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importa %d evento</item>
|
||||||
|
<item quantity="many">Importa %d eventi</item>
|
||||||
|
<item quantity="other">Importa %d eventi</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">Importato %d evento.</item>
|
||||||
|
<item quantity="many">Importati %d eventi.</item>
|
||||||
|
<item quantity="other">Importati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
|
||||||
|
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||||
|
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||||
|
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||||
|
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||||
|
<string name="crash_dialog_report">Report</string>
|
||||||
|
<string name="crash_dialog_dismiss">Non ora</string>
|
||||||
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
|
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||||
|
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||||
|
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||||
|
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||||
|
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||||
|
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||||
|
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s fino a %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d volte</string>
|
||||||
|
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
|
||||||
|
<string name="agenda_header_today">Oggi</string>
|
||||||
|
<string name="widget_next_month">Mese prossimo</string>
|
||||||
|
<string name="widget_today">Oggi</string>
|
||||||
|
<string name="settings_title">Impostazioni</string>
|
||||||
|
<string name="settings_back">Indietro</string>
|
||||||
|
<string name="agenda_range_this_week">Questa settimana</string>
|
||||||
|
<string name="settings_reminders">Promemoria evento</string>
|
||||||
|
<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>
|
</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>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||||
<string name="permission_request_button">Grant calendar access</string>
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
<string name="permission_denied_title">Calendar access denied</string>
|
<string name="permission_denied_title">Calendar access denied</string>
|
||||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||||
@@ -68,6 +68,8 @@
|
|||||||
<string name="event_edit_close">Close</string>
|
<string name="event_edit_close">Close</string>
|
||||||
<string name="event_edit_save">Save</string>
|
<string name="event_edit_save">Save</string>
|
||||||
<string name="event_edit_title_hint">Add title</string>
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
|
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||||
|
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||||
<string name="event_edit_starts">Starts</string>
|
<string name="event_edit_starts">Starts</string>
|
||||||
<string name="event_edit_ends">Ends</string>
|
<string name="event_edit_ends">Ends</string>
|
||||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||||
@@ -360,6 +362,35 @@
|
|||||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15) -->
|
||||||
|
<string name="settings_section_special_dates">Contact special dates</string>
|
||||||
|
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||||
|
<string name="settings_special_dates_template">Title format</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
|
||||||
|
<string name="settings_special_dates_reminders">Reminders</string>
|
||||||
|
<string name="settings_special_dates_show_year">Show year</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||||
|
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||||
|
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Paused</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||||
|
<string name="settings_special_dates_grant">Grant access</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||||
|
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||||
|
<string name="dialog_save">Save</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
<string name="settings_license">License</string>
|
<string name="settings_license">License</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
@@ -478,4 +509,13 @@
|
|||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||||
|
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||||
|
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||||
|
<string name="special_dates_calendar_custom">Special dates</string>
|
||||||
|
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -10,4 +10,6 @@
|
|||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="de" />
|
<locale android:name="de" />
|
||||||
|
<locale android:name="es" />
|
||||||
|
<locale android:name="it" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
|
|||||||
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
|
||||||
|
val today = LocalDate.of(2026, 8, 1)
|
||||||
|
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
|
||||||
|
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence returns today when the date is today`() {
|
||||||
|
val today = LocalDate.of(2026, 3, 10)
|
||||||
|
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
|
||||||
|
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
|
||||||
|
.isEqualTo(LocalDate.of(2028, 2, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
|
||||||
|
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
|
||||||
|
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
|
||||||
|
// hour; sampling at the upcoming occurrence fires at the intended time.
|
||||||
|
val anchor1972 = LocalDate.of(1972, 7, 15)
|
||||||
|
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
|
||||||
|
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
|
||||||
|
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
|
||||||
|
|
||||||
|
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
|
||||||
|
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||||
|
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
|
||||||
|
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||||
|
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
|
||||||
|
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
|
|||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long? = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long? = 1_000_003_600L,
|
||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
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
|
@Test
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun deleteCalendar(id: Long) {
|
override fun deleteCalendar(id: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedCalendarIds += id
|
deletedCalendarIds += id
|
||||||
|
managedCalendarRows.removeAll { it.id == id }
|
||||||
|
managedEvents.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||||
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun deleteEvent(eventId: Long) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedEventIds += eventId
|
deletedEventIds += eventId
|
||||||
|
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||||
|
|
||||||
|
var nextManagedEventId: Long = 1_000L
|
||||||
|
private var managedCalendarSeq: Long = 700L
|
||||||
|
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||||
|
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||||
|
|
||||||
|
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||||
|
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
|
||||||
|
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||||
|
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||||
|
|
||||||
|
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
|
||||||
|
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
||||||
|
val id = managedCalendarSeq++
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||||
|
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
insertedManagedEvents += form to uid
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
|
val id = nextManagedEventId++
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
|
||||||
|
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
|
||||||
|
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
updatedManagedFields += eventId to values
|
||||||
|
managedEvents.values.forEach { rows ->
|
||||||
|
val idx = rows.indexOfFirst { it.eventId == eventId }
|
||||||
|
if (idx >= 0) {
|
||||||
|
var row = rows[idx]
|
||||||
|
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
|
||||||
|
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
|
||||||
|
if (values.containsKey(CalendarContract.Events.RRULE)) {
|
||||||
|
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
|
||||||
|
}
|
||||||
|
rows[idx] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
|||||||
@@ -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,226 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class SpecialDatesSyncEngineTest {
|
||||||
|
|
||||||
|
private class FakeContacts(
|
||||||
|
var permission: Boolean = true,
|
||||||
|
var dates: List<ContactSpecialDate> = emptyList(),
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
override fun hasPermission() = permission
|
||||||
|
override fun readSpecialDates() = dates
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSpec : SpecialDatesCalendarSpec {
|
||||||
|
override fun displayName(type: SpecialDateType) = type.name
|
||||||
|
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "{name}'s birthday ({year})"
|
||||||
|
SpecialDateType.Anniversary -> "{name}'s anniversary"
|
||||||
|
SpecialDateType.Custom -> "{name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(tempDir: Path): SettingsPrefs {
|
||||||
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
return SettingsPrefs(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
|
||||||
|
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
|
||||||
|
|
||||||
|
private suspend fun engineWith(
|
||||||
|
tempDir: Path,
|
||||||
|
contacts: FakeContacts,
|
||||||
|
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||||
|
val settings = prefs(tempDir)
|
||||||
|
if (enabled) settings.setSpecialDatesEnabled(true)
|
||||||
|
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(
|
||||||
|
dates = listOf(
|
||||||
|
birthday("a", "Jane", 5, 14, 1990),
|
||||||
|
birthday("b", "Bob", 12, 25, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
|
||||||
|
// All three type calendars exist; the birthday one holds both contacts.
|
||||||
|
assertThat(calendars.createdManagedCalendars.map { it.type })
|
||||||
|
.containsExactlyElementsIn(SpecialDateType.entries)
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(2)
|
||||||
|
assertThat(calendars.insertedManagedEvents.map { it.second })
|
||||||
|
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
|
||||||
|
// Year-less contact drops the year; year-known keeps it.
|
||||||
|
val titles = calendars.insertedManagedEvents.map { it.first.title }
|
||||||
|
assertThat(titles).contains("Jane's birthday (1990)")
|
||||||
|
assertThat(titles).contains("Bob's birthday")
|
||||||
|
// Calendar ids were persisted for the editor / next sync.
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
engine.sync()
|
||||||
|
val insertsAfterFirst = calendars.insertedManagedEvents.size
|
||||||
|
val calendarsAfterFirst = calendars.createdManagedCalendars.size
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
|
||||||
|
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
|
||||||
|
assertThat(calendars.updatedManagedFields).isEmpty()
|
||||||
|
assertThat(calendars.deletedEventIds).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.updatedManagedFields).hasSize(1)
|
||||||
|
val (_, columns) = calendars.updatedManagedFields.single()
|
||||||
|
// Only managed columns are ever written — never reminders/location.
|
||||||
|
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
|
||||||
|
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
contacts.dates = emptyList()
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.deletedEventIds).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||||
|
engine.sync()
|
||||||
|
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
|
||||||
|
|
||||||
|
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
|
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
|
||||||
|
|
||||||
|
// Persisted as the per-calendar all-day override (so new events match)...
|
||||||
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
||||||
|
.isEqualTo(listOf(0, 1440))
|
||||||
|
// ...and applied to the existing events in that calendar.
|
||||||
|
assertThat(calendars.appliedManagedReminders).contains(
|
||||||
|
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||||
|
engine.sync()
|
||||||
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
|
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
|
||||||
|
|
||||||
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
||||||
|
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure diff ---
|
||||||
|
|
||||||
|
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventDesired(uid, title, dt, rrule)
|
||||||
|
|
||||||
|
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventRow(id, uid, title, dt, rrule)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
|
||||||
|
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
|
||||||
|
)
|
||||||
|
assertThat(diff.insertUids).containsExactly("new")
|
||||||
|
assertThat(diff.deleteEventIds).containsExactly(2L)
|
||||||
|
assertThat(diff.updates).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff emits only the changed managed columns`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("a", "Janet", 150)),
|
||||||
|
existing = listOf(row(9, "a", "Jane", 100)),
|
||||||
|
)
|
||||||
|
val update = diff.updates.single()
|
||||||
|
assertThat(update.eventId).isEqualTo(9L)
|
||||||
|
assertThat(update.columns.keys)
|
||||||
|
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -465,4 +466,59 @@ class SettingsPrefsTest {
|
|||||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesEnabled.first()).isFalse()
|
||||||
|
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
|
||||||
|
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
|
||||||
|
assertThat(prefs.managedCalendarIds.first()).isEmpty()
|
||||||
|
assertThat(prefs.specialDatesShowYear.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||||
|
assertThat(prefs.specialDatesTypes.first())
|
||||||
|
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
|
||||||
|
assertThat(prefs.specialDatesCalendars.first())
|
||||||
|
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
|
||||||
|
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
|
||||||
|
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
|
||||||
|
assertThat(prefs.specialDatesCalendars.first())
|
||||||
|
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
|
||||||
|
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
|
||||||
|
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
|
||||||
|
.isEqualTo("{name}'s birthday")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesStatus.first())
|
||||||
|
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
|
||||||
|
|
||||||
|
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
assertThat(prefs.specialDatesStatus.first())
|
||||||
|
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
|
||||||
|
|
||||||
|
prefs.recordSpecialDatesRun(2000L, null)
|
||||||
|
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ContactSpecialDateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a full yyyy-MM-dd date with year`() {
|
||||||
|
val parts = parseContactEventDate("1990-05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less --MM-dd date`() {
|
||||||
|
val parts = parseContactEventDate("--05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a compact yyyyMMdd date`() {
|
||||||
|
val parts = parseContactEventDate("19900514")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less MM-dd date without the double-dash prefix`() {
|
||||||
|
val parts = parseContactEventDate("12-25")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
|
||||||
|
val parts = parseContactEventDate("--02-29")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps Feb 29 in a real leap year`() {
|
||||||
|
assertThat(parseContactEventDate("2000-02-29"))
|
||||||
|
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects Feb 29 in a non-leap year`() {
|
||||||
|
assertThat(parseContactEventDate("1999-02-29")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an impossible month or day`() {
|
||||||
|
assertThat(parseContactEventDate("2020-13-01")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020-06-31")).isNull()
|
||||||
|
assertThat(parseContactEventDate("--00-10")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects blank, null and garbage input`() {
|
||||||
|
assertThat(parseContactEventDate(null)).isNull()
|
||||||
|
assertThat(parseContactEventDate("")).isNull()
|
||||||
|
assertThat(parseContactEventDate(" ")).isNull()
|
||||||
|
assertThat(parseContactEventDate("not-a-date")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SpecialDateFormattingTest {
|
||||||
|
|
||||||
|
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
|
||||||
|
lookupKey = "k",
|
||||||
|
displayName = "Jane",
|
||||||
|
type = SpecialDateType.Birthday,
|
||||||
|
month = month,
|
||||||
|
day = day,
|
||||||
|
year = year,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uid is deterministic and namespaced by type`() {
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
|
||||||
|
.isEqualTo("contact-birthday:abc@calendula")
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
|
||||||
|
.isEqualTo("contact-anniversary:abc@calendula")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
|
||||||
|
lookupKey = "k",
|
||||||
|
displayName = "Jane",
|
||||||
|
type = SpecialDateType.Custom,
|
||||||
|
month = month,
|
||||||
|
day = day,
|
||||||
|
year = null,
|
||||||
|
label = label,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `birthday and anniversary uids ignore the discriminator`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two custom dates on one contact get distinct uids by label`() {
|
||||||
|
assertThat(custom("Wedding", 6, 10).managedUid())
|
||||||
|
.isEqualTo("contact-custom:k:wedding@calendula")
|
||||||
|
assertThat(custom("Graduation", 9, 1).managedUid())
|
||||||
|
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
|
||||||
|
assertThat(custom(null, 6, 10).managedUid())
|
||||||
|
.isEqualTo("contact-custom:k:06-10@calendula")
|
||||||
|
assertThat(custom(null, 9, 1).managedUid())
|
||||||
|
.isNotEqualTo(custom(null, 6, 10).managedUid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date uses the known year`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date falls back to a leap year when year-less`() {
|
||||||
|
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title substitutes name and year`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
|
||||||
|
.isEqualTo("Jane's birthday (1990)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title drops an empty year parenthesis and tidies spacing`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
|
||||||
|
.isEqualTo("Jane's birthday")
|
||||||
|
}
|
||||||
|
}
|
||||||
248
docs/design/contact-special-dates.md
Normal file
248
docs/design/contact-special-dates.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
|
||||||
|
|
||||||
|
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
|
||||||
|
Date: 2026-06-30 (implemented 2026-07-01)
|
||||||
|
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
|
||||||
|
|
||||||
|
Implementation notes / deviations from this design:
|
||||||
|
|
||||||
|
- **Event identity key is a deterministic `Events.UID_2445`**
|
||||||
|
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
|
||||||
|
the provider drops `_sync` columns unless written through a sync-adapter URI;
|
||||||
|
`UID_2445` is written/read through the normal event URIs the app already uses
|
||||||
|
(and doubles as a stable iCal UID). The managed **calendar** still carries a
|
||||||
|
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
|
||||||
|
sync-adapter URI).
|
||||||
|
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
|
||||||
|
age is impossible without per-year exception rows (rejected as too heavy). The
|
||||||
|
template instead offers `{year}` — the birth year (or an anniversary's start
|
||||||
|
year) — which is static and correct on every occurrence. A "Show year" toggle
|
||||||
|
gates it; it's in the default templates ("{name}'s birthday ({year})").
|
||||||
|
- New managed calendars seed a per-calendar all-day reminder default of
|
||||||
|
on-the-day + one week before, so birthdays get lead time out of the box.
|
||||||
|
- Title template is user-editable per type from day one (`{name}`, `{year}`).
|
||||||
|
|
||||||
|
This document captures the full design for surfacing contact birthdays —
|
||||||
|
and, by extension, anniversaries and custom dates — as auto-updating local
|
||||||
|
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
|
||||||
|
default reminders), which is a hard prerequisite and ships first.
|
||||||
|
|
||||||
|
The two pieces are deliberately split across **two branches / two
|
||||||
|
shipments**:
|
||||||
|
|
||||||
|
- `feat/per-calendar-multi-reminders` — #14, ships first.
|
||||||
|
- `feat/contact-special-dates` — #15, builds on #14.
|
||||||
|
|
||||||
|
They *may* release together depending on timing, but #14 is independently
|
||||||
|
useful and is the foundation the special-dates calendars lean on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this is worth doing carefully
|
||||||
|
|
||||||
|
Calendula's identity is privacy: **no INTERNET permission**, and historically
|
||||||
|
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
|
||||||
|
never holds the contacts permission (`EventEditScreen.kt:446-460`).
|
||||||
|
|
||||||
|
This feature **declares `READ_CONTACTS`** for the first time. That is a
|
||||||
|
deliberate, owner-approved shift, mitigated by:
|
||||||
|
|
||||||
|
- The permission is **opt-in and feature-gated** — requested only when the user
|
||||||
|
enables the feature, never at startup.
|
||||||
|
- Everything stays **offline**. Contacts are read locally and mirrored into a
|
||||||
|
local calendar; nothing leaves the device.
|
||||||
|
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
|
||||||
|
Contacts.
|
||||||
|
|
||||||
|
Store listing / About copy will need to explain the optional permission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisite — #14: per-calendar *multiple* default reminders
|
||||||
|
|
||||||
|
### Today
|
||||||
|
|
||||||
|
- Events already support **multiple reminders** — `EventForm.reminders:
|
||||||
|
List<Int>` (`EventForm.kt:24`), each written as a separate
|
||||||
|
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
|
||||||
|
firing independently. The runtime/notification path is done.
|
||||||
|
- The **defaults** layer is single-valued, though:
|
||||||
|
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
|
||||||
|
`Int` (`SettingsPrefs.kt:287,302`).
|
||||||
|
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
|
||||||
|
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
|
||||||
|
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
|
||||||
|
- UI: expandable per-calendar override section
|
||||||
|
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
|
||||||
|
|
||||||
|
### The change
|
||||||
|
|
||||||
|
Widen the **defaults**, not the runtime, from one reminder to a list:
|
||||||
|
|
||||||
|
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
|
||||||
|
override + `resolveDefaultReminder()`. The DataStore string format already
|
||||||
|
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
|
||||||
|
comma-separated list. Keep the `Inherit / None / Minutes` override states
|
||||||
|
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
|
||||||
|
a list.
|
||||||
|
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
|
||||||
|
UI rather than inventing new interaction.
|
||||||
|
3. New events seed `EventForm.reminders` from the **resolved list** instead of
|
||||||
|
a single value.
|
||||||
|
|
||||||
|
No new permissions, no provider changes, no scheduling changes. Type widening
|
||||||
|
plus one picker upgrade.
|
||||||
|
|
||||||
|
### Why it gates #15
|
||||||
|
|
||||||
|
Each special-dates calendar is a normal local calendar, so it inherits the
|
||||||
|
**per-calendar reminder default** for free. A birthdays calendar is useless
|
||||||
|
without lead time (the reporter literally asked for "a week before *and* on the
|
||||||
|
day") — that requires *multiple* defaults, i.e. #14.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #15: Contact special-dates calendars
|
||||||
|
|
||||||
|
### Core model: one-way mirror, one local calendar per type
|
||||||
|
|
||||||
|
Contacts → **separate local calendars per type**:
|
||||||
|
|
||||||
|
- "Birthdays"
|
||||||
|
- "Anniversaries"
|
||||||
|
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
|
||||||
|
|
||||||
|
Separate-per-type is the key architectural decision: each is a normal local
|
||||||
|
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
|
||||||
|
**existing per-calendar infra applies for free**:
|
||||||
|
|
||||||
|
- **Color** → existing per-calendar color.
|
||||||
|
- **Show/hide** → existing calendar visibility.
|
||||||
|
- **Default reminders** → per-calendar override from #14.
|
||||||
|
|
||||||
|
This shrinks the new surface dramatically: anniversaries and custom dates are
|
||||||
|
near copy-paste of birthdays (same engine, different `ContactsContract` data
|
||||||
|
kind, one extra local calendar each), and the settings sub-page does **not**
|
||||||
|
need to reinvent color/visibility/reminders.
|
||||||
|
|
||||||
|
### Events: field-level managed, not read-only
|
||||||
|
|
||||||
|
Calendula manages a few fields; the user owns the rest.
|
||||||
|
|
||||||
|
- **Managed** (overwritten on every sync, disabled in the editor for these
|
||||||
|
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
|
||||||
|
- **User-owned** (seeded once on insert, never touched again by sync):
|
||||||
|
location, reminders, notes, busy/free.
|
||||||
|
|
||||||
|
The rule that makes this safe: sync performs a **targeted column update**
|
||||||
|
(managed columns only), **never delete-and-reinsert**. Reminders live in
|
||||||
|
separate rows, so once seeded they are simply never re-touched. A user can move
|
||||||
|
a birthday reminder to "2 weeks before" or add a location and neither is
|
||||||
|
clobbered.
|
||||||
|
|
||||||
|
In the editor, when an event belongs to a managed calendar: disable the
|
||||||
|
title/date/recurrence fields; allow the rest.
|
||||||
|
|
||||||
|
### Reconciliation identity
|
||||||
|
|
||||||
|
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
|
||||||
|
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
|
||||||
|
|
||||||
|
- contact added → insert
|
||||||
|
- birthday/date changed → targeted update of managed columns
|
||||||
|
- contact or date removed → delete the event
|
||||||
|
|
||||||
|
Getting this wrong = duplicate birthdays on every sync, so the identity key is
|
||||||
|
non-negotiable.
|
||||||
|
|
||||||
|
### Data source
|
||||||
|
|
||||||
|
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
|
||||||
|
`Event.TYPE`:
|
||||||
|
|
||||||
|
- `TYPE_BIRTHDAY` → Birthdays calendar
|
||||||
|
- `TYPE_ANNIVERSARY` → Anniversaries calendar
|
||||||
|
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
|
||||||
|
|
||||||
|
### Dates: year-less + age
|
||||||
|
|
||||||
|
Many contacts store `--MM-DD` (no year). Handle both:
|
||||||
|
|
||||||
|
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
|
||||||
|
age ("Jane turns 30") when the show-age option is on.
|
||||||
|
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
|
||||||
|
|
||||||
|
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
|
||||||
|
(`Recurrence.kt:32-174`).
|
||||||
|
|
||||||
|
### Auto-update cadence
|
||||||
|
|
||||||
|
Birthdays change rarely, so nothing live/expensive:
|
||||||
|
|
||||||
|
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
|
||||||
|
(catches new/edited/deleted contacts even if the app isn't opened).
|
||||||
|
- **No `ContentObserver`** — needs the process alive and buys almost nothing
|
||||||
|
for once-a-year events.
|
||||||
|
|
||||||
|
### Permission + lifecycle
|
||||||
|
|
||||||
|
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
|
||||||
|
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
|
||||||
|
grant, create the enabled calendars and run the first sync.
|
||||||
|
- **Disable:** delete the managed calendars + their events (with a confirm).
|
||||||
|
- **Permission revoked in system settings:** detect on next sync; surface that
|
||||||
|
the feature is stalled rather than failing silently.
|
||||||
|
|
||||||
|
### Configurable settings sub-page
|
||||||
|
|
||||||
|
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
|
||||||
|
color/visibility/reminders already live in normal calendar settings, the
|
||||||
|
sub-page only carries the genuinely new knobs:
|
||||||
|
|
||||||
|
- Master enable toggle.
|
||||||
|
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
|
||||||
|
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
|
||||||
|
- Show-age toggle (only meaningful when the year is known).
|
||||||
|
- (Possibly) sync-cadence / "sync now".
|
||||||
|
- Pointer to the normal calendar settings for color, visibility, reminders.
|
||||||
|
|
||||||
|
The owner wants this broadly configurable — keep the sub-page the home for
|
||||||
|
display/behaviour options, and lean on existing per-calendar settings for the
|
||||||
|
rest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions / deferred
|
||||||
|
|
||||||
|
- Title template: free-form vs a small set of presets. Start with one good
|
||||||
|
localized default; revisit configurability.
|
||||||
|
- Whether "Custom dates" is one bucket or split further by label. Start: one
|
||||||
|
bucket.
|
||||||
|
- Contact scope: all contacts vs per-account/group filtering. Start: all;
|
||||||
|
filtering is a later refinement.
|
||||||
|
|
||||||
|
## Task outline
|
||||||
|
|
||||||
|
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
|
||||||
|
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
|
||||||
|
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
|
||||||
|
- [x] `resolveDefaultReminder()` returns a list.
|
||||||
|
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
|
||||||
|
- [x] Seed new events' `EventForm.reminders` from the resolved list.
|
||||||
|
- [x] Migration / parse compatibility for existing single-value stored prefs.
|
||||||
|
- [x] Tests: resolution, parse round-trip, all-day variant.
|
||||||
|
|
||||||
|
### #15 — contact special-dates (`feat/contact-special-dates`)
|
||||||
|
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
|
||||||
|
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
|
||||||
|
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
|
||||||
|
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
|
||||||
|
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
|
||||||
|
user-owned fields on insert only.
|
||||||
|
- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
|
||||||
|
- [x] Auto-update: on enable, on foreground, daily WorkManager.
|
||||||
|
- [x] Editor: disable managed fields for managed-calendar events.
|
||||||
|
- [x] Revoked-permission detection + stalled-state surface.
|
||||||
|
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
|
||||||
|
- [x] Translatable strings (titles, sub-page).
|
||||||
|
- [x] About / store copy for the optional `READ_CONTACTS` permission.
|
||||||
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
|
|
||||||
@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
|||||||
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||||
Termin.
|
Termin.
|
||||||
|
|
||||||
|
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
|
||||||
|
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
|
||||||
|
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
|
||||||
|
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
|
||||||
|
kontrollierst.
|
||||||
|
|
||||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||||
|
|
||||||
|
|||||||
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
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]).
|
||||||
@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
|
|||||||
simple repeat picker. Calendula also delivers your event reminders as
|
simple repeat picker. Calendula also delivers your event reminders as
|
||||||
notifications — tap one and you're on the event.
|
notifications — tap one and you're on the event.
|
||||||
|
|
||||||
|
Optionally, show your contacts' birthdays, anniversaries and other special
|
||||||
|
dates in your calendar. You turn this on yourself; contacts are read only on
|
||||||
|
your device, are never uploaded, and are never changed — Calendula only
|
||||||
|
mirrors the dates into local calendars you fully control.
|
||||||
|
|
||||||
The differentiator is the design: real Material 3 Expressive throughout,
|
The differentiator is the design: real Material 3 Expressive throughout,
|
||||||
with dynamic color, expressive motion, and expressive shapes.
|
with dynamic color, expressive motion, and expressive shapes.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user