Compare commits
75 Commits
fix/caldav
...
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 | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 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/
|
||||
|
||||
# Claude Code
|
||||
/CLAUDE.md
|
||||
|
||||
82
CHANGELOG.md
82
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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [2.13.1] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Create events from other apps and widgets. Calendula now registers the
|
||||
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||
hand off to Calendula to create a new event. It opens the new-event form
|
||||
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||
description, recurrence), and picks your last-used or first writable calendar.
|
||||
Thanks to @dschuermann for the suggestion ([#30]).
|
||||
|
||||
### Fixed
|
||||
- Some recurring events could not be opened. Events in a series that started
|
||||
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||
— showed "Something went wrong" instead of opening, because their stored start
|
||||
time is a negative value that was wrongly treated as invalid. They now open
|
||||
normally and appear in search again. A related case (an event whose stored end
|
||||
precedes its start) is now kept and openable instead of failing the same way.
|
||||
Thanks to @dschuermann for the report ([#34]).
|
||||
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||
24-hour time, the clock dial for choosing an event's start and end time still
|
||||
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||
@abrossimow for the report ([#27]).
|
||||
|
||||
## [2.13.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
- 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,
|
||||
@@ -22,17 +68,20 @@ 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
|
||||
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
|
||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||
app (such as DAVx5), the event colour picker showed every colour the account
|
||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||
duplicates or near-identical shades. The picker now shows only visually
|
||||
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||
colours still sync exactly as before, and calendars with hand-picked
|
||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||
([#22]).
|
||||
- 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
|
||||
@@ -47,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
|
||||
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]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
@@ -773,8 +827,14 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
versionCode = 21301
|
||||
versionName = "2.13.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<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
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
@@ -103,6 +112,17 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events dir mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
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.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -34,11 +37,15 @@ import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -57,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -81,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -99,9 +112,22 @@ class MainActivity : AppCompatActivity() {
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
// The user's custom-font choice, resolved to a Material typography
|
||||
// (issue #19). Recomputed only when a token — or the custom-font
|
||||
// re-import stamp AppFontSettings carries, so replacing the file
|
||||
// behind an active "custom" token still refreshes — changes;
|
||||
// "system for both" returns the default scale untouched.
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||
)
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
@@ -116,6 +142,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -153,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +201,31 @@ class MainActivity : AppCompatActivity() {
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
if (action != Intent.ACTION_INSERT) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
|
||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
||||
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
|
||||
* [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)
|
||||
}
|
||||
@@ -20,15 +20,18 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
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.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -58,10 +61,7 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||
* publish all ~147 CSS3 names, #22) and hue-sorted; see
|
||||
* [curatedForPicker]. Empty when the
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||
* which a synced calendar may drop on its next sync.
|
||||
@@ -101,6 +101,55 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
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]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -296,6 +345,140 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
@@ -424,7 +607,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.curatedForPicker()
|
||||
?.sortedBy { it.key }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
@@ -579,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.distinct()
|
||||
|
||||
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
|
||||
// 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
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||
// (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 globally-unique UID so a later .ics backup/restore can identify
|
||||
// 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
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
val values = buildEventInsertValues(
|
||||
form,
|
||||
uid = "${UUID.randomUUID()}@calendula",
|
||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||
).toContentValues().apply {
|
||||
// A null colour just leaves both columns unset (the event inherits
|
||||
// its calendar's colour), so only the key/raw cases are written.
|
||||
when {
|
||||
@@ -619,19 +782,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Best effort (spec §8): the event exists at this point — a reminder
|
||||
// that fails to attach is logged, not surfaced as a failed create.
|
||||
encodedReminders(form, allDayReminderTimeMinutes)
|
||||
.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
// 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.
|
||||
@@ -639,6 +790,47 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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 {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A managed all-day event's reminders, encoded so they fire at
|
||||
* [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(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
@@ -1042,6 +1234,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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 {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
@@ -1051,6 +1254,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
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
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
|
||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
} else {
|
||||
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>,
|
||||
reminders: List<Reminder>,
|
||||
): EventDetail? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||
// Dropping negatives made every occurrence of such a series un-openable
|
||||
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||
Log.w(TAG, "Dropping event with missing dtstart")
|
||||
return null
|
||||
}
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||
// but dropping the row would make the event un-openable — the same trap as
|
||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
|
||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
||||
"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
|
||||
* 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,
|
||||
// so the mapper only reads it for local calendars).
|
||||
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 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_DISPLAY_NAME = 1
|
||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
||||
const val IDX_VISIBLE = 5
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||
// long-running birthdays/anniversaries still surface in search.
|
||||
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||
if (dtStart < 0L) return null
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
|
||||
@@ -0,0 +1,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.CalendarRepository
|
||||
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.ReminderAlertStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.data.fonts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import android.graphics.fonts.Font as PlatformFont
|
||||
|
||||
/**
|
||||
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||
* system file picker is copied into the app's private storage — one file per
|
||||
* [FontRole] — so the selection survives even if the original is later moved or
|
||||
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||
* typography.
|
||||
*/
|
||||
object CustomFontStore {
|
||||
|
||||
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||
|
||||
/** The stored font file for [role] (may not exist yet). */
|
||||
fun file(context: Context, role: FontRole): File =
|
||||
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||
|
||||
fun exists(context: Context, role: FontRole): Boolean =
|
||||
file(context, role).let { it.exists() && it.length() > 0 }
|
||||
|
||||
/**
|
||||
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||
* font. Returns true on success; on any failure the previous file is left
|
||||
* untouched and false is returned (the caller keeps the old selection).
|
||||
*/
|
||||
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||
val target = file(context, role)
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
// Font.Builder throws IOException for anything that isn't a valid
|
||||
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||
PlatformFont.Builder(tmp).build()
|
||||
if (target.exists() && !target.delete()) return false
|
||||
tmp.renameTo(target)
|
||||
} catch (_: Exception) {
|
||||
tmp.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the custom font for [role] (used when switching away from it). */
|
||||
fun clear(context: Context, role: FontRole) {
|
||||
file(context, role).delete()
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
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.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -106,6 +109,47 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||
*/
|
||||
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
suspend fun setBrandFont(token: String) {
|
||||
store.edit { it[BRAND_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
suspend fun setPlainFont(token: String) {
|
||||
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||
* overwrites the same file under the same "custom" token, so the token alone
|
||||
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||
*/
|
||||
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||
val key = when (role) {
|
||||
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||
}
|
||||
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
@@ -219,6 +263,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||
* instead of each re-serialising a stale copy of the other field over it.
|
||||
*/
|
||||
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||
store.edit { prefs ->
|
||||
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||
* are appended in default order and unknown names dropped. Absent key means
|
||||
@@ -457,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) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -501,6 +647,10 @@ class SettingsPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
@@ -545,9 +695,30 @@ class SettingsPrefs @Inject constructor(
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
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]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
@@ -566,6 +737,18 @@ sealed interface 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
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
@@ -651,7 +834,15 @@ private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
id to parts[1].toReminderList()
|
||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
||||
// override (empty list); a non-sentinel value that parses to no valid
|
||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
||||
// global default rather than silently reading as "no reminder".
|
||||
when (val value = parts[1]) {
|
||||
NONE -> id to emptyList()
|
||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,28 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||
* reminder must be suppressed (mirroring the event filtering in
|
||||
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||
* gate is built from: [postableAlerts] here and the choke point in
|
||||
* [ReminderNotifier.post] both use it.
|
||||
*/
|
||||
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||
calendarId != 0L && calendarId in disabledCalendarIds
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||
* provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
@@ -45,6 +57,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -60,11 +73,16 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
val postable = postableAlerts(due, disabled)
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
// the suppressed ones. Stash those suppressed alerts so
|
||||
// re-enabling their calendar can recover them (they would
|
||||
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||
postable.forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -33,6 +34,7 @@ import javax.inject.Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
// The single choke point for the disabled-calendar gate: it covers both
|
||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||
// longer notifies — without either receiver duplicating the check.
|
||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Still relevant while the event has not ended: a reminder for an event that is
|
||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||
*/
|
||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||
* the user re-enables the calendar before the event is over.
|
||||
*
|
||||
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||
* domain model. Entries whose event has already ended are dropped on the next
|
||||
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||
*/
|
||||
@Singleton
|
||||
class SuppressedReminderStore @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||
if (alerts.isEmpty()) return
|
||||
store.edit { prefs ->
|
||||
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||
alerts.forEach { byId[it.alertId] = it }
|
||||
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||
*/
|
||||
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||
val recovered = mutableListOf<ReminderAlert>()
|
||||
store.edit { prefs ->
|
||||
val kept = decodeAll(prefs).filter { alert ->
|
||||
when {
|
||||
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
prefs.putStash(kept)
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||
suspend fun purgeExpired(nowMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||
|
||||
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||
}
|
||||
}
|
||||
|
||||
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||
private const val FIELD_SEP = "|"
|
||||
private const val NULL_LOCATION = "-"
|
||||
|
||||
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||
alert.alertId.toString(),
|
||||
alert.eventId.toString(),
|
||||
alert.calendarId.toString(),
|
||||
alert.beginMillis.toString(),
|
||||
alert.endMillis.toString(),
|
||||
if (alert.isAllDay) "1" else "0",
|
||||
alert.title.toBase64(),
|
||||
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||
).joinToString(FIELD_SEP)
|
||||
|
||||
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||
val parts = raw.split(FIELD_SEP)
|
||||
if (parts.size != 8) return null
|
||||
return try {
|
||||
ReminderAlert(
|
||||
alertId = parts[0].toLong(),
|
||||
eventId = parts[1].toLong(),
|
||||
calendarId = parts[2].toLong(),
|
||||
beginMillis = parts[3].toLong(),
|
||||
endMillis = parts[4].toLong(),
|
||||
title = parts[6].fromBase64(),
|
||||
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||
isAllDay = parts[5] == "1",
|
||||
)
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) { // bad Base64
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toBase64(): String =
|
||||
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun String.fromBase64(): String =
|
||||
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||
@@ -1,179 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
*
|
||||
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
*
|
||||
* Three steps, all in painted space:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||
* seam lands in dead space and no hue family is torn across both ends.
|
||||
*
|
||||
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||
* still round-trips through sync.
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||
* circle at its widest angular gap so the single seam lands in empty space
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||
)
|
||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||
var cutAfter = byHue.lastIndex
|
||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||
for (i in 0 until byHue.lastIndex) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
widestGap = gap
|
||||
cutAfter = i
|
||||
}
|
||||
}
|
||||
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||
}
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch (mirrored by
|
||||
* ui/pastelize, which wraps this): keep the hue, scale and clamp saturation
|
||||
* into a gentle band, and pin value to a constant so nothing screams and
|
||||
* everything reads on the surface. Value is fixed here so curation is
|
||||
* theme-independent — only hue and saturation distinguish painted swatches.
|
||||
*/
|
||||
fun pastelArgb(rawArgb: Int): Int {
|
||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||
val b = (rawArgb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||
val s = sat.coerceIn(0.25f, 0.65f)
|
||||
val v = PASTEL_VALUE
|
||||
val c = v * s
|
||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||
val m = v - c
|
||||
val (rr, gg, bb) = when {
|
||||
hue < 60f -> Triple(c, x, 0f)
|
||||
hue < 120f -> Triple(x, c, 0f)
|
||||
hue < 180f -> Triple(0f, c, x)
|
||||
hue < 240f -> Triple(0f, x, c)
|
||||
hue < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||
}
|
||||
|
||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||
private const val PASTEL_VALUE = 0.82f
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||
private const val MIN_DELTA_E = 13.0
|
||||
|
||||
/**
|
||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
|
||||
fun deltaE(other: Lab): Double =
|
||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||
|
||||
companion object {
|
||||
fun of(argb: Int): Lab {
|
||||
fun linear(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
val r = linear(16)
|
||||
val g = linear(8)
|
||||
val b = linear(0)
|
||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||
val fy = f(y)
|
||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||
*/
|
||||
enum class FontRole { BRAND, PLAIN }
|
||||
@@ -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.
|
||||
*/
|
||||
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(
|
||||
|
||||
@@ -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 = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -176,6 +178,16 @@ fun CalendarHost(
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||
// issue #30) arrives already prefilled — open it in the same create form the
|
||||
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
@@ -272,6 +284,7 @@ fun CalendarHost(
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
@@ -303,6 +316,7 @@ fun CalendarHost(
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -33,6 +35,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -49,6 +53,12 @@ fun RootScreen(
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == 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)
|
||||
@@ -76,6 +86,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -191,6 +192,7 @@ fun AgendaScreen(
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
@@ -289,6 +291,7 @@ private fun AgendaContent(
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -318,6 +321,7 @@ private fun AgendaContent(
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -333,6 +337,7 @@ private fun AgendaList(
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -342,7 +347,7 @@ private fun AgendaList(
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -362,10 +367,16 @@ private fun AgendaList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
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.SettingsPrefs
|
||||
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.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
@@ -142,7 +142,7 @@ fun CalendarsScreen(
|
||||
sessionKey = editorSession,
|
||||
isNew = editorId == NEW_CALENDAR_ID,
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
@@ -507,7 +507,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
|
||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(setOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||
* unposted because those gates are closed are simply released.
|
||||
*/
|
||||
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||
if (reEnabledIds.isEmpty()) return
|
||||
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||
if (recovered.isNotEmpty() &&
|
||||
settingsPrefs.remindersEnabled.first() &&
|
||||
notifier.canPost()
|
||||
) {
|
||||
recovered.forEach { notifier.post(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.pastelArgb
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*
|
||||
* The hue/saturation shaping lives in [pastelArgb] so the event-colour picker's
|
||||
* curation reasons about the exact colour painted here (#22); this only picks
|
||||
* the theme's brightness.
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
||||
* whose title shrinks into the bar (next to the back button) as the content
|
||||
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
||||
* scroll. Used by Settings and the calendar manager so they share one shell.
|
||||
* The app's standard full-screen list scaffold. By default a collapsing
|
||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
||||
* the content scrolls — used by Settings and the calendar manager, where the
|
||||
* large header sets the page. Content is a scrollable column that feeds the
|
||||
* toolbar via nested scroll.
|
||||
*
|
||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
||||
* Preferred for selection pickers, where the tall header is just wasted space
|
||||
* above the options.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
largeTopBar: Boolean = true,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollBehavior =
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
val navigationIcon = @Composable {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
val colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
if (largeTopBar) {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
) { innerPadding ->
|
||||
|
||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = if (textStyle.color.isSpecified) {
|
||||
textStyle.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,8 +46,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
||||
* pinned single-line title rather than the large collapsing header — a picker is
|
||||
* a short list, so the tall header would only be empty space to scroll past.
|
||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
@@ -71,7 +73,12 @@ fun FullScreenPicker(
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
}
|
||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
onBack = onDismiss,
|
||||
largeTopBar = false,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +123,16 @@ fun <T> OptionPicker(
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||
* default reminder" row above the list; choosing it defers to the global default
|
||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
||||
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||
* back.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -132,48 +143,54 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Optimistic local state: the chosen override is authoritative while the
|
||||
// picker is open, so quick successive toggles compose on each other instead
|
||||
// of racing the round-trip through the settings flow (which would drop a
|
||||
// toggle made before the previous write echoes back). Seeded once on open;
|
||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
// on each other instead of racing the round-trip through the settings flow
|
||||
// (which would drop a toggle made before the previous write echoes back).
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||
// mirroring [selected]: a picker that first composed against the settings
|
||||
// flow's initialValue (empty — reachable after process-death restore straight
|
||||
// onto the Notifications page, before the CalendarProvider-gated combine
|
||||
// resolves) then adopts the real stored default instead of persisting empty
|
||||
// on the first tap. After the first edit, local state is authoritative.
|
||||
var userEdited by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(selected) {
|
||||
if (!userEdited) current = selected
|
||||
}
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val isNone = current is CalendarReminderOverride.None
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||
// which would strand a hand-entered value with no way to re-check it.
|
||||
val seenCustom = remember { mutableSetOf<Int>() }
|
||||
seenCustom += selectedMinutes.filter { it !in presets }
|
||||
// Presets plus every custom value seen this session, each as its own row.
|
||||
val rows = presets + seenCustom.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||
fun emit(minutes: List<Int>) {
|
||||
val norm = minutes.distinct().sorted()
|
||||
apply(
|
||||
if (norm.isEmpty()) {
|
||||
CalendarReminderOverride.None
|
||||
} else {
|
||||
CalendarReminderOverride.Minutes(norm)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||
// the multi-select list, since inheriting is mutually exclusive with
|
||||
// picking specific times.
|
||||
// Exclusive choices in their own group above the multi-select list, since
|
||||
// each is mutually exclusive with picking specific times: "use default"
|
||||
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||
// empty state stays visible and deliberately reachable).
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
@@ -182,8 +199,19 @@ fun ReminderDefaultPicker(
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_none),
|
||||
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||
selected = isNone,
|
||||
trailing = if (isNone) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
@@ -222,6 +250,27 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
||||
* silently wipe the calendar's default, and to explicit
|
||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
||||
* means no reminder). Pure so it can be unit-tested.
|
||||
*/
|
||||
internal fun reminderOverrideForMinutes(
|
||||
minutes: List<Int>,
|
||||
allowInherit: Boolean,
|
||||
): CalendarReminderOverride {
|
||||
val norm = minutes.distinct().sorted()
|
||||
return when {
|
||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
||||
allowInherit -> CalendarReminderOverride.Inherit
|
||||
else -> CalendarReminderOverride.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||
* row above it (matching the grouped-row system, so the two read as one
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -43,8 +44,10 @@ private val RowGap: Dp = 2.dp
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the held row settles into its slot, then the
|
||||
* order is committed with a single [onReorder] call.
|
||||
* its way (animated); on release the order is committed immediately with a
|
||||
* single [onReorder] call, then the held row eases into its new slot as a
|
||||
* purely visual settle — safe to interrupt with another drag, since the
|
||||
* commit itself never waits on it.
|
||||
*
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
@@ -66,16 +69,29 @@ fun <T> ReorderableColumn(
|
||||
var order by remember(items) { mutableStateOf(items) }
|
||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||
// Live translation of the held row from its slot (px); also drives the
|
||||
// release settle, animated back onto a slot before the order is committed.
|
||||
// release settle — re-based onto the row's new slot once the order commits
|
||||
// (see onDragEnd), then eased down to zero.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
// Whole slots dragged → the slot the held row currently hovers over.
|
||||
val targetIndex = draggedIndex?.let {
|
||||
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
}
|
||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||
// so recomposition only fires when the *target slot* actually changes, not on
|
||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||
// held row's own translation is already applied in the draw phase via
|
||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||
// the loop, so the derived value is captured into a real local instead.)
|
||||
val targetIndex = remember(items, pitchPx) {
|
||||
derivedStateOf {
|
||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||
}
|
||||
}.value
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||
order.forEachIndexed { index, item ->
|
||||
@@ -112,16 +128,25 @@ fun <T> ReorderableColumn(
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
if (from < 0) return@detectDragGestures
|
||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
if (to != from) {
|
||||
// Commit synchronously and unconditionally, before the settle
|
||||
// animation below runs — the commit must not depend on that
|
||||
// coroutine reaching its end, or a new drag starting within the
|
||||
// ~160ms settle window would cancel it and silently revert an
|
||||
// already-finished reorder.
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
// The row now lays out at slot `to` instead of `from`; re-base
|
||||
// the live offset onto that new slot (same visual position,
|
||||
// expressed relative to the new one) so the settle below eases
|
||||
// it the rest of the way instead of jumping.
|
||||
dragOffset -= (to - from) * pitchPx
|
||||
}
|
||||
settleJob = scope.launch {
|
||||
// Settle the held row onto its target slot, then commit —
|
||||
// resetting offset and slot in the same frame, so nothing jumps.
|
||||
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
|
||||
dragOffset = value
|
||||
}
|
||||
if (to != from) {
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
}
|
||||
// Purely visual from here: ease the held row onto its slot, then
|
||||
// release the drag state. Safe to cancel — the order was already
|
||||
// committed above.
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
||||
onConfirm: (LocalTime) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Honour the app's own time-format preference (the value every time label
|
||||
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||
// still showed an AM/PM dial (issue #27).
|
||||
val state = rememberTimePickerState(
|
||||
initialHour = initial.hour,
|
||||
initialMinute = initial.minute,
|
||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||
is24Hour = LocalUse24HourFormat.current,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
||||
text = { TimePicker(state = state) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||
*
|
||||
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||
* the *app's* context locale — and this app applies a per-app language
|
||||
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||
* (Resources.getSystem), not the app's.
|
||||
*/
|
||||
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||
"24" -> true
|
||||
"12" -> false
|
||||
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||
else -> {
|
||||
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,14 +250,18 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
// Managed special-dates events have no delete — the sync
|
||||
// would resurrect them; the date is removed at the contact.
|
||||
if (!s.isManaged) {
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
||||
val calendarName: String?,
|
||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
detail = corrected,
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
@@ -119,13 +120,16 @@ import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
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.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
@@ -435,6 +439,10 @@ private fun EventEditContent(
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
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 showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -519,6 +527,7 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
enabled = !locked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
@@ -531,6 +540,17 @@ private fun EventEditContent(
|
||||
.height(3.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))
|
||||
|
||||
@@ -549,7 +569,11 @@ private fun EventEditContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
||||
Switch(
|
||||
checked = form.isAllDay,
|
||||
onCheckedChange = viewModel::setAllDay,
|
||||
enabled = !locked,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -561,6 +585,7 @@ private fun EventEditContent(
|
||||
locale = locale,
|
||||
onDateClick = { picker = PickerTarget.StartDate },
|
||||
onTimeClick = { picker = PickerTarget.StartTime },
|
||||
enabled = !locked,
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_ends),
|
||||
@@ -570,6 +595,7 @@ private fun EventEditContent(
|
||||
onDateClick = { picker = PickerTarget.EndDate },
|
||||
onTimeClick = { picker = PickerTarget.EndTime },
|
||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||
enabled = !locked,
|
||||
)
|
||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -700,7 +726,7 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = { showRecurrencePicker = true },
|
||||
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -943,7 +969,7 @@ private fun EventEditContent(
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPickerDialog(
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
selectedId = form.calendarId,
|
||||
onSelect = {
|
||||
@@ -1712,26 +1738,18 @@ private fun ColorPickerDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (palette.isNotEmpty()) {
|
||||
// The event's current colour may not be in the curated
|
||||
// palette (thinned near-duplicate, or a raw colour set
|
||||
// elsewhere) — append it so the selection ring has a home.
|
||||
val swatches = palette.map { it.argb }.let {
|
||||
if (selected != null && selected !in it) it + selected else it
|
||||
}
|
||||
ColorSwatchRow(
|
||||
colors = swatches,
|
||||
colors = palette.map { it.argb },
|
||||
selected = selected,
|
||||
onSelect = { argb ->
|
||||
val option = palette.firstOrNull { it.argb == argb }
|
||||
// The appended current colour has no key to write —
|
||||
// it is already the event's colour, so just close.
|
||||
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
|
||||
palette.firstOrNull { it.argb == argb }
|
||||
?.let { onPickKey(it.key, it.argb) }
|
||||
},
|
||||
dark = dark,
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
@@ -1866,6 +1884,7 @@ private fun InlineField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1879,6 +1898,7 @@ private fun InlineField(
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
)
|
||||
}
|
||||
@@ -1893,6 +1913,7 @@ private fun ScheduleRow(
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val dateFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -1902,11 +1923,12 @@ private fun ScheduleRow(
|
||||
timeOfDayFormatter(use24Hour, locale)
|
||||
}
|
||||
// Tappable values read as links (primary), like the location on the
|
||||
// detail screen; errors flip them to the error colour.
|
||||
val valueColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||
// row dims to a plain, non-tappable value.
|
||||
val valueColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1923,7 +1945,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onDateClick)
|
||||
.clickable(enabled = enabled, onClick = onDateClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!isAllDay) {
|
||||
@@ -1932,41 +1954,71 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onTimeClick)
|
||||
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen calendar picker: a scrollable list of every writable calendar,
|
||||
* grouped under its owning account like the visibility filter and calendar
|
||||
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
|
||||
* list past ~9 rows so accounts with many calendars had unreachable entries
|
||||
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarPickerDialog(
|
||||
private fun CalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
label = calendar.displayName,
|
||||
onClick = { onSelect(calendar.id) },
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
// The calendar's own colour carries its identity.
|
||||
iconTint = pastelize(calendar.color, dark),
|
||||
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
|
||||
selected = calendar.id == selectedId,
|
||||
)
|
||||
}
|
||||
// Group by owning account (name, else type, else the calendar's own name),
|
||||
// preserving the provider's order within and across groups — the same
|
||||
// grouping [groupByAccount] applies for the drawer filter.
|
||||
val groups = remember(calendars) {
|
||||
calendars
|
||||
.groupBy {
|
||||
it.accountName.takeIf(String::isNotBlank)
|
||||
?: it.accountType.takeIf(String::isNotBlank)
|
||||
?: it.displayName
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
.toList()
|
||||
}
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
groups.forEach { (account, cals) ->
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
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
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
|
||||
@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
|
||||
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
|
||||
* 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
|
||||
* 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(
|
||||
repository.calendars(),
|
||||
allCalendars,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||
}.catch { emit(emptyList()) }
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||
@@ -165,21 +171,33 @@ class EventEditViewModel @Inject constructor(
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
) { local, external, palette ->
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
?: external.writable.firstOrNull()?.id
|
||||
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
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = external.writable,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
@@ -417,7 +435,10 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -37,7 +39,9 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -53,6 +57,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +67,7 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -76,6 +82,7 @@ import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -86,24 +93,30 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
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.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
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.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||
@@ -122,13 +135,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Calendar
|
||||
|
||||
/** 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).
|
||||
@@ -192,7 +209,19 @@ fun SettingsScreen(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +275,13 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||
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)
|
||||
// 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
|
||||
@@ -498,6 +534,18 @@ private fun AppearanceScreen(
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
|
||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||
val context = LocalContext.current
|
||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.fontImportFailed.collect {
|
||||
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -534,6 +582,24 @@ private fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Fonts — the two Material typeface roles, each independently choosable
|
||||
// (issue #19). Headings = brand (display/headline); body = plain
|
||||
// (title/body/label). Both default to the system typeface.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_font_headings),
|
||||
summary = fontLabel(fonts.brand),
|
||||
position = Position.Top,
|
||||
onClick = { showBrandFont = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_font_body),
|
||||
summary = fontLabel(fonts.plain),
|
||||
position = Position.Bottom,
|
||||
onClick = { showPlainFont = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Calendar — view, week, and timeline formatting
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
@@ -625,6 +691,28 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
}
|
||||
if (showBrandFont) {
|
||||
FontPicker(
|
||||
title = stringResource(R.string.settings_font_headings),
|
||||
role = FontRole.BRAND,
|
||||
selected = fonts.brand,
|
||||
stamp = fonts.brandStamp,
|
||||
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||
onDismiss = { showBrandFont = false },
|
||||
)
|
||||
}
|
||||
if (showPlainFont) {
|
||||
FontPicker(
|
||||
title = stringResource(R.string.settings_font_body),
|
||||
role = FontRole.PLAIN,
|
||||
selected = fonts.plain,
|
||||
stamp = fonts.plainStamp,
|
||||
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||
onDismiss = { showPlainFont = false },
|
||||
)
|
||||
}
|
||||
if (showWeekStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
@@ -712,7 +800,7 @@ private fun ViewsScreen(
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
|
||||
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
val checked = view in config.enabled
|
||||
ViewRow(
|
||||
@@ -726,10 +814,7 @@ private fun ViewsScreen(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on ->
|
||||
val enabled = if (on) config.enabled + view else config.enabled - view
|
||||
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
|
||||
},
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -925,6 +1010,7 @@ private fun NotificationsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
@@ -1049,6 +1135,25 @@ private fun NotificationsScreen(
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
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
|
||||
// Calendar card; tapping expands it into a grouped list
|
||||
// of three (the card + the timed and all-day rows).
|
||||
@@ -1171,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. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
@@ -1254,22 +1640,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
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. */
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||
// than a (non-composable) joinToString transform.
|
||||
val parts = ArrayList<String>(minutes.size)
|
||||
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
||||
return parts.joinToString(", ")
|
||||
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||
}
|
||||
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@@ -1332,6 +1707,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
},
|
||||
)
|
||||
|
||||
/** The summary label for a stored font token (issue #19). */
|
||||
@Composable
|
||||
private fun fontLabel(token: String): String = when (token) {
|
||||
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
||||
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
||||
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
||||
?: stringResource(R.string.settings_font_system)
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME types offered to the document picker so it lists only font files. Covers
|
||||
* the modern `font/` types plus the legacy `application/` font aliases some
|
||||
* providers still report. Anything that slips through is still validated by
|
||||
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
||||
*/
|
||||
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/sfnt",
|
||||
"font/collection",
|
||||
"application/x-font-ttf",
|
||||
"application/x-font-otf",
|
||||
"application/font-sfnt",
|
||||
"application/vnd.ms-opentype",
|
||||
)
|
||||
|
||||
/**
|
||||
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||
* font (previewed in its own face), and "Choose file…" which opens the system
|
||||
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||
* a file is validated and imported by the caller, switching to the custom font
|
||||
* on success.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontPicker(
|
||||
title: String,
|
||||
role: FontRole,
|
||||
selected: String,
|
||||
stamp: Int,
|
||||
onSelect: (String) -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onImport(uri)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// System default + the bundled fonts + the "Choose file…" row.
|
||||
val rowCount = BundledFont.entries.size + 2
|
||||
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
|
||||
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
|
||||
// refreshes the preview while plain recompositions reuse the cached family.
|
||||
val customPreview = remember(role, isCustom, stamp) {
|
||||
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
FontOptionRow(
|
||||
label = stringResource(R.string.settings_font_system),
|
||||
preview = FontFamily.Default,
|
||||
selected = selected == FONT_SYSTEM_TOKEN,
|
||||
position = positionOf(0, rowCount),
|
||||
onClick = {
|
||||
onSelect(FONT_SYSTEM_TOKEN)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
BundledFont.entries.forEachIndexed { index, font ->
|
||||
FontOptionRow(
|
||||
label = stringResource(font.labelRes),
|
||||
preview = font.family,
|
||||
selected = selected == font.token,
|
||||
position = positionOf(index + 1, rowCount),
|
||||
onClick = {
|
||||
onSelect(font.token)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
FontOptionRow(
|
||||
label = if (isCustom) {
|
||||
stringResource(R.string.settings_font_custom_selected)
|
||||
} else {
|
||||
stringResource(R.string.settings_font_choose_file)
|
||||
},
|
||||
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||
preview = customPreview,
|
||||
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||
selected = isCustom,
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||
* to preview), and a check when it's the current selection.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontOptionRow(
|
||||
label: String,
|
||||
preview: FontFamily?,
|
||||
selected: Boolean,
|
||||
position: Position,
|
||||
onClick: () -> Unit,
|
||||
leadingIcon: ImageVector? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = label,
|
||||
position = position,
|
||||
selected = selected,
|
||||
leading = {
|
||||
if (preview != null) {
|
||||
Text(
|
||||
text = "Ag",
|
||||
fontFamily = preview,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
} else if (leadingIcon != null) {
|
||||
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||
}
|
||||
},
|
||||
trailing = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.SettingsPrefs
|
||||
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.domain.CalendarSource
|
||||
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.common.CalendarView
|
||||
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).
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@@ -10,23 +11,38 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.fonts.CustomFontStore
|
||||
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.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
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.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -36,12 +52,16 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -87,8 +107,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarReminderOverride,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
},
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
@@ -133,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
||||
perCalendarReminderOverride = overrides.timed,
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -140,6 +162,29 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
/**
|
||||
* The app-wide custom-font choice (issue #19). Its own flow — both this
|
||||
* screen and [MainActivity] (which builds the typography) read it
|
||||
* independently of the main settings combine.
|
||||
*/
|
||||
val fontState: StateFlow<AppFontSettings> = combine(
|
||||
prefs.brandFont,
|
||||
prefs.plainFont,
|
||||
prefs.brandFontStamp,
|
||||
prefs.plainFontStamp,
|
||||
) { brand, plain, brandStamp, plainStamp ->
|
||||
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AppFontSettings(),
|
||||
)
|
||||
|
||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||
// surfaces it and the previous selection stays put.
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||
|
||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||
// and can leave the widget on the intermediate value. Held sequentially,
|
||||
@@ -158,6 +203,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
@@ -181,6 +227,95 @@ class SettingsViewModel @Inject constructor(
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -189,6 +324,35 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
}
|
||||
|
||||
fun setFont(role: FontRole, token: String) {
|
||||
viewModelScope.launch {
|
||||
when (role) {
|
||||
FontRole.BRAND -> prefs.setBrandFont(token)
|
||||
FontRole.PLAIN -> prefs.setPlainFont(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a user-picked font file for [role]. Copies and validates it off the
|
||||
* main thread; on success the role switches to the custom font, otherwise the
|
||||
* previous choice is kept and [fontImportFailed] fires.
|
||||
*/
|
||||
fun importCustomFont(role: FontRole, uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||
if (ok) {
|
||||
// Bump the stamp so replacing an already-active custom font (same
|
||||
// file, same "custom" token) still breaks font-settings equality
|
||||
// and refreshes the resolved typeface.
|
||||
prefs.bumpCustomFontStamp(role)
|
||||
setFont(role, FONT_CUSTOM_TOKEN)
|
||||
} else {
|
||||
_fontImportFailed.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
@@ -262,8 +426,26 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||
}
|
||||
|
||||
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
|
||||
/**
|
||||
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
|
||||
* read-modify-write so a concurrent reorder can't clobber this toggle (and
|
||||
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
|
||||
* re-checked inside the transform: the screen's own guard reads the
|
||||
* async-echoed snapshot, so two quick disable-taps could both look allowed
|
||||
* yet compose to a below-minimum cycle.
|
||||
*/
|
||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.updateQuickSwitch { config ->
|
||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
|
||||
fun setQuickSwitchOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
|
||||
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import java.io.File
|
||||
|
||||
/** Stored token for "use the device default typeface" — the default for both roles. */
|
||||
const val FONT_SYSTEM_TOKEN = "system"
|
||||
|
||||
/** Stored token for "use the font the user loaded from a file" (per role). */
|
||||
const val FONT_CUSTOM_TOKEN = "custom"
|
||||
|
||||
/**
|
||||
* A font checked into the app that a user can pick without providing a file
|
||||
* (issue #19). [token] is the stable value persisted in preferences; [family] is
|
||||
* the resolved Compose family. Kept deliberately small and readability-focused.
|
||||
*/
|
||||
enum class BundledFont(
|
||||
val token: String,
|
||||
@StringRes val labelRes: Int,
|
||||
val family: FontFamily,
|
||||
) {
|
||||
/** Purpose-built for low-vision legibility (Braille Institute). */
|
||||
AtkinsonHyperlegible(
|
||||
token = "atkinson_hyperlegible",
|
||||
labelRes = R.string.font_atkinson_hyperlegible,
|
||||
family = FontFamily(
|
||||
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
|
||||
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
|
||||
),
|
||||
),
|
||||
|
||||
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
|
||||
Lora(
|
||||
token = "lora",
|
||||
labelRes = R.string.font_lora,
|
||||
family = FontFamily(
|
||||
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
|
||||
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
|
||||
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
|
||||
),
|
||||
),
|
||||
|
||||
/** A monospace option. */
|
||||
JetBrainsMono(
|
||||
token = "jetbrains_mono",
|
||||
labelRes = R.string.font_jetbrains_mono,
|
||||
family = FontFamily(
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||
),
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
|
||||
}
|
||||
}
|
||||
|
||||
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
|
||||
|
||||
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
|
||||
data class AppFontSettings(
|
||||
val brand: String = FONT_SYSTEM_TOKEN,
|
||||
val plain: String = FONT_SYSTEM_TOKEN,
|
||||
// Per-role custom-font re-import stamp: bumped when the "custom" file is
|
||||
// replaced, so a same-token re-import still breaks equality and refreshes the
|
||||
// resolved family. 0 for existing installs / no import yet (issue #19).
|
||||
val brandStamp: Int = 0,
|
||||
val plainStamp: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
|
||||
* "leave the Material default" (the system typeface). A custom token whose file
|
||||
* is missing or unreadable also resolves to null, so a deleted file degrades to
|
||||
* the system font rather than crashing text layout.
|
||||
*/
|
||||
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
|
||||
FONT_SYSTEM_TOKEN -> null
|
||||
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
|
||||
else -> BundledFont.fromToken(token)?.family
|
||||
}
|
||||
|
||||
private fun customFontFamily(file: File): FontFamily? =
|
||||
if (file.exists() && file.length() > 0) {
|
||||
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the app typography from the chosen [brand] and [plain] families. A null
|
||||
* family leaves that role's Material default (system typeface) in place, so the
|
||||
* common "system for both" case returns [CalendulaTypography] untouched. Brand
|
||||
* drives display + headline; plain drives title + body + label (M3's two
|
||||
* typeface roles).
|
||||
*/
|
||||
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
|
||||
if (brand == null && plain == null) return CalendulaTypography
|
||||
val base = CalendulaTypography
|
||||
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
|
||||
if (family != null) copy(fontFamily = family) else this
|
||||
return base.copy(
|
||||
displayLarge = base.displayLarge.withFamily(brand),
|
||||
displayMedium = base.displayMedium.withFamily(brand),
|
||||
displaySmall = base.displaySmall.withFamily(brand),
|
||||
headlineLarge = base.headlineLarge.withFamily(brand),
|
||||
headlineMedium = base.headlineMedium.withFamily(brand),
|
||||
headlineSmall = base.headlineSmall.withFamily(brand),
|
||||
titleLarge = base.titleLarge.withFamily(plain),
|
||||
titleMedium = base.titleMedium.withFamily(plain),
|
||||
titleSmall = base.titleSmall.withFamily(plain),
|
||||
bodyLarge = base.bodyLarge.withFamily(plain),
|
||||
bodyMedium = base.bodyMedium.withFamily(plain),
|
||||
bodySmall = base.bodySmall.withFamily(plain),
|
||||
labelLarge = base.labelLarge.withFamily(plain),
|
||||
labelMedium = base.labelMedium.withFamily(plain),
|
||||
labelSmall = base.labelSmall.withFamily(plain),
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* - System light/dark.
|
||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||
* derived from [CalendulaSeed].
|
||||
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
||||
* Material scale on the system typeface.
|
||||
*
|
||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||
* but the V1 foundation just follows the system.
|
||||
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
||||
* bare defaults (used by the crash screen) just follow the system.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CalendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
typography: Typography = CalendulaTypography,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
@@ -42,7 +46,7 @@ fun CalendulaTheme(
|
||||
// the bouncy variant felt overdone in review (2026-06-11).
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CalendulaTypography,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
fun WeekScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -249,6 +250,7 @@ fun WeekScreen(
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
@@ -268,6 +270,7 @@ private fun WeekContent(
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -342,6 +345,7 @@ private fun WeekContent(
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
@@ -355,6 +359,7 @@ private fun WeekSuccess(
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -363,7 +368,7 @@ private fun WeekSuccess(
|
||||
.fillMaxWidth()
|
||||
.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)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
@@ -427,7 +432,11 @@ private fun WeekTopBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||
private fun WeekDayHeader(
|
||||
days: List<LocalDate>,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val weekStart = days.first()
|
||||
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 isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onOpenDay(date) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
|
||||
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/lora.ttf
Normal file
BIN
app/src/main/res/font/lora.ttf
Normal file
Binary file not shown.
@@ -12,7 +12,7 @@
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
@@ -309,7 +309,7 @@
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
|
||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||
<string name="settings_section_about">Über</string>
|
||||
@@ -387,4 +387,27 @@
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">Woche</item>
|
||||
<item quantity="other">Wochen</item>
|
||||
</plurals>
|
||||
<string name="settings_default_view">Standartansicht</string>
|
||||
<string name="settings_font_headings">Überschriften Schriftart</string>
|
||||
<string name="settings_font_system">Systemstandard</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Datei auswählen…</string>
|
||||
<string name="settings_font_import_failed">Die Datei konnte nicht als Schriftart gelesen werden</string>
|
||||
<string name="settings_dim_completed">Vergangene Ereignisse abdunkeln</string>
|
||||
<string name="settings_dim_completed_summary">Vergangene Termine in der Monats- und Wochenansicht abdunkeln</string>
|
||||
<string name="settings_past_events">Vergangene Termine</string>
|
||||
<string name="settings_past_events_show">Zeigen</string>
|
||||
<string name="settings_past_events_dim">Abdunkeln</string>
|
||||
<string name="settings_past_events_hide">Ausblenden</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,464 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</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>
|
||||
|
||||
@@ -6,4 +6,454 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</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>
|
||||
|
||||
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) -->
|
||||
<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_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>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_save">Save</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_ends">Ends</string>
|
||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||
@@ -274,6 +276,15 @@
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</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">Choose file…</string>
|
||||
<string name="settings_font_custom_selected">Custom font</string>
|
||||
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
@@ -351,6 +362,35 @@
|
||||
<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_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_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -469,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="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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -10,4 +10,6 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="it" />
|
||||
</locale-config>
|
||||
|
||||
@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
|
||||
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
|
||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||
|
||||
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
|
||||
eventColor: Any? = null,
|
||||
eventColorKey: String? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
dtstart: Long = 1_000_000_000L,
|
||||
dtend: Long = 1_000_003_600L,
|
||||
dtstart: Long? = 1_000_000_000L,
|
||||
dtend: Long? = 1_000_003_600L,
|
||||
allDay: Int = 0,
|
||||
location: String? = "Berlin",
|
||||
calendarId: Long = 7L,
|
||||
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dtend before dtstart drops detail`() {
|
||||
fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
|
||||
// A backwards DTEND is malformed, but dropping it would make the event
|
||||
// un-openable (the "Something went wrong" trap, same as issue #34); keep
|
||||
// it as a zero-length event so the user can still open and fix it.
|
||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||
assertThat(detail).isNull()
|
||||
assertThat(detail).isNotNull()
|
||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L)
|
||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||
// A yearly birthday/anniversary anchored before the epoch has a
|
||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||
// no DTEND (they use DURATION), so it stays end == begin.
|
||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
||||
assertThat(detail).isNotNull()
|
||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent dtstart drops detail`() {
|
||||
assertThat(detailReader(dtstart = null).toDetail()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
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.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun deleteCalendar(id: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
managedCalendarRows.removeAll { it.id == id }
|
||||
managedEvents.remove(id)
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
writeError?.let { throw it }
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
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.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -198,6 +200,22 @@ class SettingsPrefsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
// A toggle then a reorder, each expressed against the *stored* config —
|
||||
// the reorder must not resurrect the just-disabled view (the stale-UI-
|
||||
// snapshot race this API exists to prevent).
|
||||
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
|
||||
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||
prefs.updateQuickSwitch { it.copy(order = newOrder) }
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
|
||||
assertThat(config.enabled).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -319,6 +337,26 @@ class SettingsPrefsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// Only "none" deliberately means an explicit no-reminder override; a
|
||||
// value with no valid minutes is garbage and must drop its entry so the
|
||||
// calendar inherits the global default instead of silently losing
|
||||
// reminders. Partially-valid values keep their salvageable minutes.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
|
||||
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -402,6 +440,19 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
// Existing installs have no stamp key — that must read as 0.
|
||||
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
|
||||
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
|
||||
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||
prefs.bumpCustomFontStamp(FontRole.PLAIN)
|
||||
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
|
||||
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||
@@ -415,4 +466,59 @@ class SettingsPrefsTest {
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,20 @@ class PostableAlertsTest {
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alert with unknown calendar id 0 is never treated as disabled`() {
|
||||
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
|
||||
val preUpgrade = alert(1, calendarId = 0L)
|
||||
|
||||
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
|
||||
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
|
||||
.containsExactly(preUpgrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isForDisabledCalendar matches only the disabled ids`() {
|
||||
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
|
||||
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
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 kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class SuppressedReminderStoreTest {
|
||||
|
||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
private fun alert(
|
||||
alertId: Long,
|
||||
calendarId: Long,
|
||||
endMillis: Long = Long.MAX_VALUE,
|
||||
title: String = "Event $alertId",
|
||||
location: String? = null,
|
||||
) = ReminderAlert(
|
||||
alertId = alertId,
|
||||
eventId = alertId * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = 0L,
|
||||
endMillis = endMillis,
|
||||
title = title,
|
||||
location = location,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `encode then decode round-trips every field including delimiters`() {
|
||||
val original = alert(
|
||||
alertId = 7,
|
||||
calendarId = 42,
|
||||
endMillis = 123_456_789L,
|
||||
// Free-text with the field separator and other awkward characters.
|
||||
title = "Lunch | with | Alice",
|
||||
location = "Café, 3rd floor | room B",
|
||||
).copy(beginMillis = 100L, isAllDay = true)
|
||||
|
||||
val decoded = decodeStashEntry(encodeStashEntry(original))
|
||||
|
||||
assertThat(decoded).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode returns null for a malformed entry`() {
|
||||
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null location round-trips`() {
|
||||
val original = alert(1, calendarId = 1, location = null)
|
||||
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
val keep = alert(1, calendarId = 100)
|
||||
val recoverA = alert(2, calendarId = 200)
|
||||
val recoverB = alert(3, calendarId = 200)
|
||||
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
|
||||
|
||||
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
|
||||
|
||||
assertThat(recovered).containsExactly(recoverA, recoverB)
|
||||
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
|
||||
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash drops alerts whose event already ended`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
val past = alert(1, calendarId = 100, endMillis = 500L)
|
||||
val future = alert(2, calendarId = 100, endMillis = 2_000L)
|
||||
|
||||
store.stash(listOf(past, future), nowMillis = 1_000L)
|
||||
|
||||
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purgeExpired removes only entries past their event end`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
// Stash both before "now" so neither is dropped on write, then advance time.
|
||||
store.stash(
|
||||
listOf(
|
||||
alert(1, calendarId = 100, endMillis = 500L),
|
||||
alert(2, calendarId = 100, endMillis = 2_000L),
|
||||
),
|
||||
nowMillis = 0L,
|
||||
)
|
||||
|
||||
store.purgeExpired(nowMillis = 1_000L)
|
||||
|
||||
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
|
||||
.containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash replaces an existing entry with the same alert id`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
|
||||
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
|
||||
|
||||
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
|
||||
|
||||
assertThat(recovered).hasSize(1)
|
||||
assertThat(recovered.single().title).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isRelevantAt is true up to the event end and false after`() {
|
||||
val a = alert(1, calendarId = 1, endMillis = 1_000L)
|
||||
assertThat(a.isRelevantAt(999L)).isTrue()
|
||||
assertThat(a.isRelevantAt(1_000L)).isTrue()
|
||||
assertThat(a.isRelevantAt(1_001L)).isFalse()
|
||||
}
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventColorPaletteTest {
|
||||
|
||||
@Test
|
||||
fun `empty palette stays empty`() {
|
||||
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact duplicate values collapse to the alphabetically first key`() {
|
||||
val curated = listOf(
|
||||
EventColorOption("cyan", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("aqua", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small palettes pass through whole, so Google's curated set is untouched`() {
|
||||
// A Google-like palette: two dozen distinct hand-picked colours.
|
||||
val palette = (0 until 24).map {
|
||||
val hue = it * 15
|
||||
EventColorOption("$it", hsvArgb(hue.toFloat()))
|
||||
}
|
||||
|
||||
val curated = palette.curatedForPicker()
|
||||
|
||||
assertThat(curated).containsExactlyElementsIn(palette)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
|
||||
val curated = css3Palette().curatedForPicker()
|
||||
|
||||
// The whole point of #22: ~147 published colours become a single
|
||||
// manageable grid instead of a full screen.
|
||||
assertThat(curated.size).isAtLeast(30)
|
||||
assertThat(curated.size).isAtMost(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `curation never invents colours or drops keys`() {
|
||||
val source = css3Palette()
|
||||
val curated = source.curatedForPicker()
|
||||
|
||||
assertThat(source).containsAtLeastElementsIn(curated)
|
||||
assertThat(curated.map { it.argb }).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spelling-alias pairs never both survive`() {
|
||||
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||
|
||||
val aliasPairs = listOf(
|
||||
"aqua" to "cyan",
|
||||
"fuchsia" to "magenta",
|
||||
"gray" to "grey",
|
||||
"darkgray" to "darkgrey",
|
||||
"dimgray" to "dimgrey",
|
||||
"lightgray" to "lightgrey",
|
||||
"slategray" to "slategrey",
|
||||
"lightslategray" to "lightslategrey",
|
||||
"darkslategray" to "darkslategrey",
|
||||
)
|
||||
aliasPairs.forEach { (a, b) ->
|
||||
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
||||
// Black and every gray paint as the same pale swatch (the picker pins
|
||||
// lightness and floors saturation), so only one survives — no stranded
|
||||
// run of look-alike "pinks" at the end of the grid (#22).
|
||||
val curated = listOf(
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
|
||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker().map { it.key }
|
||||
|
||||
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dark and a light shade of one hue collapse to a single swatch`() {
|
||||
// The picker paints every swatch at one fixed lightness, so navy and a
|
||||
// mid blue are indistinguishable once painted — keep just one.
|
||||
val curated = listOf(
|
||||
EventColorOption("navy", 0xFF000080.toInt()),
|
||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
assertThat(curated).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel is cut once, keeping each hue family contiguous`() {
|
||||
// Twelve pure hues, deliberately shuffled; a small palette passes the
|
||||
// thinning stage untouched so only the ordering is under test.
|
||||
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
|
||||
val curated = shuffledHues
|
||||
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
|
||||
.curatedForPicker()
|
||||
.map { it.key.toInt() }
|
||||
|
||||
// A proper single-seam sweep around the wheel descends exactly once
|
||||
// (at the seam). The old bucketed sort could scatter a family across
|
||||
// both ends, producing extra descents.
|
||||
val descents = curated.indices.count { i ->
|
||||
curated[(i + 1) % curated.size] < curated[i]
|
||||
}
|
||||
assertThat(descents).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CSS3 survivors span the whole rainbow`() {
|
||||
val keys = css3Palette().curatedForPicker().map { it.key }
|
||||
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
|
||||
|
||||
// Which exact name represents a hue family depends on the vivid-first
|
||||
// thinning, so assert each family survives, not a specific key.
|
||||
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
|
||||
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
|
||||
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
|
||||
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
|
||||
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
|
||||
}
|
||||
|
||||
private fun hsvArgb(hue: Float): Int {
|
||||
val h = hue / 60f
|
||||
val sector = h.toInt() % 6
|
||||
val f = h - h.toInt()
|
||||
val q = ((1 - f) * 255).toInt()
|
||||
val t = (f * 255).toInt()
|
||||
return when (sector) {
|
||||
0 -> argb(255, t, 0)
|
||||
1 -> argb(q, 255, 0)
|
||||
2 -> argb(0, 255, t)
|
||||
3 -> argb(0, q, 255)
|
||||
4 -> argb(t, 0, 255)
|
||||
else -> argb(255, 0, q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
|
||||
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
|
||||
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
|
||||
EventColorOption(name, 0xFF000000.toInt() or rgb)
|
||||
}
|
||||
|
||||
private val CSS3 = mapOf(
|
||||
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
|
||||
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
|
||||
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
|
||||
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
|
||||
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
|
||||
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
|
||||
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
|
||||
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
|
||||
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
|
||||
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
|
||||
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
|
||||
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
|
||||
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
|
||||
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
|
||||
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
|
||||
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
|
||||
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
|
||||
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
|
||||
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
|
||||
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
|
||||
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
|
||||
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
|
||||
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
|
||||
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
|
||||
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
|
||||
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
|
||||
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
|
||||
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
|
||||
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
|
||||
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
|
||||
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
|
||||
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
|
||||
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
|
||||
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
|
||||
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
|
||||
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
|
||||
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
|
||||
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
|
||||
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
|
||||
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
|
||||
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
|
||||
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
|
||||
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
|
||||
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
|
||||
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
|
||||
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
|
||||
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
|
||||
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
|
||||
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
|
||||
"yellowgreen" to 0x9ACD32,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
|
||||
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
|
||||
* reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an
|
||||
* accidental toggle-undo can't silently wipe the calendar's default) and to
|
||||
* explicit [CalendarReminderOverride.None] on the global default.
|
||||
*/
|
||||
class ReminderOverrideForMinutesTest {
|
||||
|
||||
@Test
|
||||
fun `empty selection on a per-calendar picker reverts to inherit`() {
|
||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
|
||||
.isEqualTo(CalendarReminderOverride.Inherit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty selection on the global default is explicit none`() {
|
||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
|
||||
.isEqualTo(CalendarReminderOverride.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-empty selection is normalised minutes regardless of scope`() {
|
||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
|
||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
|
||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FontsTest {
|
||||
|
||||
@Test
|
||||
fun `fromToken round-trips every bundled font`() {
|
||||
BundledFont.entries.forEach { font ->
|
||||
assertThat(BundledFont.fromToken(font.token)).isEqualTo(font)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromToken is null for system, custom, and unknown tokens`() {
|
||||
assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull()
|
||||
assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull()
|
||||
assertThat(BundledFont.fromToken("not-a-font")).isNull()
|
||||
assertThat(BundledFont.fromToken(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bumped custom-font stamp breaks font-settings equality`() {
|
||||
// Re-importing keeps the same "custom" token, so only the stamp can make
|
||||
// the settings re-emit and invalidate remember() keys downstream.
|
||||
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
|
||||
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
|
||||
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system for both roles returns the untouched default typography`() {
|
||||
assertThat(calendulaTypography(brand = null, plain = null))
|
||||
.isSameInstanceAs(CalendulaTypography)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `brand drives display and headline, plain drives title, body, and label`() {
|
||||
val brand = BundledFont.Lora.family
|
||||
val plain = BundledFont.JetBrainsMono.family
|
||||
val typography = calendulaTypography(brand = brand, plain = plain)
|
||||
|
||||
assertThat(typography.displayLarge.fontFamily).isEqualTo(brand)
|
||||
assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand)
|
||||
assertThat(typography.titleLarge.fontFamily).isEqualTo(plain)
|
||||
assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain)
|
||||
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null role keeps that role's default family while the other is applied`() {
|
||||
val plain = BundledFont.Lora.family
|
||||
val typography = calendulaTypography(brand = null, plain = plain)
|
||||
|
||||
// Brand styles keep the Material default; plain styles switch.
|
||||
assertThat(typography.displayLarge.fontFamily)
|
||||
.isEqualTo(CalendulaTypography.displayLarge.fontFamily)
|
||||
assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain)
|
||||
}
|
||||
}
|
||||
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
|
||||
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,
|
||||
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
|
||||
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,
|
||||
with dynamic color, expressive motion, and expressive shapes.
|
||||
|
||||
|
||||
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright 2020 Braille Institute of America, Inc.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
licenses/fonts/Lora-OFL.txt
Normal file
93
licenses/fonts/Lora-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
17
licenses/fonts/README.md
Normal file
17
licenses/fonts/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Bundled fonts
|
||||
|
||||
Calendula bundles the following fonts as optional user-selectable typefaces
|
||||
(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font
|
||||
License 1.1; the full license text is included alongside this file.
|
||||
|
||||
| Font | Files (`app/src/main/res/font/`) | Copyright | License |
|
||||
|------|----------------------------------|-----------|---------|
|
||||
| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) |
|
||||
| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) |
|
||||
| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) |
|
||||
|
||||
The device's default typeface (Roboto on most Android builds) remains the
|
||||
default and is not bundled.
|
||||
|
||||
Fonts a user loads from their own device via "Choose file…" are copied into the
|
||||
app's private storage and are not redistributed.
|
||||
Reference in New Issue
Block a user