Compare commits

..

3 Commits

Author SHA1 Message Date
2a035271c4 fix(edit): curate the CalDAV colour picker in painted space (#22)
The picker paints every swatch through pastelize, which pins lightness to
a constant and caps saturation — so the raw palette's lightness axis is
invisible on screen. Curating in raw space (as the first pass did) left
near-identical painted swatches (navy/blue/midnightblue all paint as one
mid blue) and stranded every neutral as an identical pale tint in a run of
"pinks" at the end of the grid.

Move dedup, thinning, and ordering into the painted (pastelised) space the
user actually sees: colours that paint identically collapse to one,
oversized palettes drop washed-out neutral-origin tints (painted chroma
below a floor) and thin by CIE76 ΔE in painted Lab, and the survivors sort
continuously by painted hue with the wheel cut at its widest gap. The CSS3
147-colour dump now lands at ~33 distinct, rainbow-ordered swatches with no
neutrals stranded at the end.

The hue/saturation shaping moves into a shared domain pastelArgb() so the
picker paints exactly what curation reasons about; ui/pastelize wraps it and
only picks the theme's brightness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:29:46 +02:00
1fe887fbc4 docs(changelog): note curated CalDAV colour picker (#22)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:37:34 +02:00
bf4f0a208d fix(edit): curate oversized CalDAV event-colour palettes (#22)
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the picker showed a full screen of
alphabetically-scrambled, partly duplicated swatches. Palettes are now
deduped by value, thinned to perceptually distinct colours (CIE76
deltaE >= 25 in Lab) when oversized, and sorted around the hue wheel
with achromatics last. Small palettes (Google's curated set) pass
through untouched, and every surviving swatch keeps its provider
colour key so picks still round-trip through sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:36:50 +02:00
86 changed files with 690 additions and 5897 deletions

View File

@@ -5,55 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.13.1] — 2026-07-06
## [Unreleased]
### 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,
@@ -68,20 +22,17 @@ 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
@@ -96,11 +47,6 @@ 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
@@ -827,14 +773,8 @@ 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
[#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
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22

View File

@@ -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 = 21301
versionName = "2.13.1"
versionCode = 21200
versionName = "2.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -5,15 +5,6 @@
<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
@@ -112,17 +103,6 @@
<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"

View File

@@ -5,8 +5,6 @@ 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
@@ -27,7 +25,6 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
reconcileAutoBackup()
reconcileSpecialDates()
}
/**
@@ -47,21 +44,4 @@ 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(),
)
}
}
}

View File

@@ -4,7 +4,6 @@ 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
@@ -26,8 +25,6 @@ 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
@@ -37,15 +34,11 @@ 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
@@ -64,11 +57,6 @@ 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.
@@ -93,7 +81,6 @@ 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
@@ -112,22 +99,9 @@ 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(
@@ -142,8 +116,6 @@ 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
@@ -181,7 +153,6 @@ class MainActivity : AppCompatActivity() {
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
}
/**
@@ -201,31 +172,6 @@ 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/

View File

@@ -50,27 +50,6 @@ 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

View File

@@ -1,22 +0,0 @@
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)
}

View File

@@ -20,18 +20,15 @@ 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
@@ -61,7 +58,10 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* (`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
* 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,55 +101,6 @@ 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
@@ -345,140 +296,6 @@ 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 {
@@ -607,7 +424,7 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?.curatedForPicker()
?: emptyList()
}
@@ -762,15 +579,35 @@ class AndroidCalendarDataSource @Inject constructor(
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
// 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 {
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 null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
@@ -782,7 +619,19 @@ 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)
seedReminders(eventId, form, allDayReminderTimeMinutes)
// 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")
}
}
// 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.
@@ -790,47 +639,6 @@ 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,
@@ -1234,17 +1042,6 @@ 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"
@@ -1254,14 +1051,6 @@ 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

View File

@@ -24,12 +24,5 @@ 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,
)
}

View File

@@ -23,29 +23,26 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): EventDetail? {
// 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")
val begin = getLong(EventDetailProjection.IDX_DTSTART)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
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. 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).
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
if (rawEnd < begin) {
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
return null
}
rawEnd
}
// Kept raw (no untitled fallback): the detail screen substitutes its own

View File

@@ -49,42 +49,6 @@ 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

View File

@@ -1,24 +0,0 @@
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?,
)

View File

@@ -15,17 +15,9 @@ 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
@@ -35,7 +27,6 @@ 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 {

View File

@@ -10,11 +10,8 @@ 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))

View File

@@ -1,42 +0,0 @@
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
}
}

View File

@@ -1,105 +0,0 @@
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,
)
}
}

View File

@@ -1,135 +0,0 @@
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
}
}

View File

@@ -1,304 +0,0 @@
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)
}

View File

@@ -14,10 +14,6 @@ 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
@@ -49,18 +45,6 @@ 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

View File

@@ -1,57 +0,0 @@
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()
}
}

View File

@@ -8,15 +8,12 @@ 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
@@ -109,47 +106,6 @@ 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() }
}
@@ -263,19 +219,6 @@ 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
@@ -514,95 +457,6 @@ 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(',')
@@ -647,10 +501,6 @@ 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")
@@ -695,30 +545,9 @@ 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. */
@@ -737,18 +566,6 @@ 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
@@ -834,15 +651,7 @@ 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
// 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
}
id to parts[1].toReminderList()
}.toMap()
}

View File

@@ -17,28 +17,16 @@ 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. 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 (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.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
@@ -57,7 +45,6 @@ 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
@@ -73,16 +60,11 @@ 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. 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) }
// the suppressed ones.
postableAlerts(due, disabled).forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
suppressedStore.stash(due - postable.toSet(), now)
suppressedStore.purgeExpired(now)
}
} finally {
pendingResult.finish()

View File

@@ -14,7 +14,6 @@ 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
@@ -34,7 +33,6 @@ 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. */
@@ -46,11 +44,6 @@ 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()

View File

@@ -1,130 +0,0 @@
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)

View File

@@ -0,0 +1,179 @@
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, 0360, 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)))
}
}
}

View File

@@ -1,8 +0,0 @@
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 }

View File

@@ -1,76 +0,0 @@
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)
}

View File

@@ -26,14 +26,6 @@ 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(

View File

@@ -1,122 +0,0 @@
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()
}

View File

@@ -1,65 +0,0 @@
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()

View File

@@ -72,8 +72,6 @@ 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
@@ -178,16 +176,6 @@ 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

View File

@@ -19,8 +19,6 @@ 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
@@ -35,8 +33,6 @@ 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 {
@@ -53,12 +49,6 @@ 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)
@@ -86,8 +76,6 @@ fun RootScreen(
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,

View File

@@ -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.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
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 ?: CalendarColorPalette.all.first(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.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 = CalendarColorPalette.all,
colors = CALENDAR_COLOR_PALETTE,
selected = color,
onSelect = { color = it },
dark = dark,

View File

@@ -14,8 +14,6 @@ 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
@@ -47,8 +45,6 @@ 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() {
@@ -148,10 +144,7 @@ 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 (!disabled) recoverReminders(setOf(id))
}
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
@@ -164,29 +157,7 @@ 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 (!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) }
if (next != current) prefs.setDisabledCalendarIds(next)
}
}

View File

@@ -15,17 +15,21 @@ 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(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}

View File

@@ -67,3 +67,16 @@ 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() }

View File

@@ -28,7 +28,6 @@ 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
@@ -58,16 +57,10 @@ fun positionOf(index: Int, count: Int): Position = when {
}
/**
* 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.
* 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.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -75,16 +68,12 @@ 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 = if (largeTopBar) {
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
@@ -92,34 +81,22 @@ fun CollapsingScaffold(
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
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,
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,
),
)
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 ->

View File

@@ -36,7 +36,6 @@ 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,
@@ -44,17 +43,15 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
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
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,

View File

@@ -24,7 +24,6 @@ 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
@@ -46,9 +45,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* 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.
* 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.
* [content] places the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
@@ -73,12 +71,7 @@ fun FullScreenPicker(
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(
title = title,
onBack = onDismiss,
largeTopBar = false,
content = content,
)
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
}
}
@@ -123,16 +116,12 @@ 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"). 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.
* "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.
*/
@Composable
fun ReminderDefaultPicker(
@@ -143,54 +132,48 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
// 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).
// 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].
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()
// 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()
// Presets plus any selected custom (non-preset) values, each as its own row.
val rows = presets + selectedMinutes.filter { it !in presets }.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)
}
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
// 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 toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
FullScreenPicker(title = title, onDismiss = onDismiss) {
// 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).
// Exclusive "use default" choice (per-calendar only): its own group above
// the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
GroupedRow(
title = stringResource(R.string.reminder_use_default),
position = Position.Top,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
@@ -199,19 +182,8 @@ 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
@@ -250,27 +222,6 @@ 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

View File

@@ -13,7 +13,6 @@ 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
@@ -44,10 +43,8 @@ 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 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.
* its way (animated); on release the held row settles into its slot, then the
* order is committed with a single [onReorder] call.
*
* [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)
@@ -69,29 +66,16 @@ 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 — re-based onto the row's new slot once the order commits
// (see onDragEnd), then eased down to zero.
// release settle, animated back onto a slot before the order is committed.
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. 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
// Whole slots dragged → the slot the held row currently hovers over.
val targetIndex = draggedIndex?.let {
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
@@ -128,25 +112,16 @@ 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 {
// 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 }
// 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)
}
draggedKey = null
dragOffset = 0f
}

View File

@@ -4,9 +4,14 @@ 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
@@ -22,13 +27,10 @@ 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 = LocalUse24HourFormat.current,
is24Hour = deviceUses24HourClock(LocalContext.current),
)
AlertDialog(
onDismissRequest = onDismiss,
@@ -43,3 +45,24 @@ 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')
}
}

View File

@@ -250,18 +250,14 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_edit),
)
}
// Managed special-dates events have no delete — the sync
// would resurrect them; the date is removed at the contact.
if (!s.isManaged) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
},

View File

@@ -15,12 +15,6 @@ 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
}

View File

@@ -154,7 +154,6 @@ class EventDetailViewModel @Inject constructor(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true,
)
} catch (e: CancellationException) {
throw e

View File

@@ -37,7 +37,6 @@ 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
@@ -120,16 +119,13 @@ 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.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
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
@@ -439,10 +435,6 @@ 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) }
@@ -527,7 +519,6 @@ 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)
@@ -540,17 +531,6 @@ 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))
@@ -569,11 +549,7 @@ private fun EventEditContent(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = form.isAllDay,
onCheckedChange = viewModel::setAllDay,
enabled = !locked,
)
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
}
},
) {
@@ -585,7 +561,6 @@ private fun EventEditContent(
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
onTimeClick = { picker = PickerTarget.StartTime },
enabled = !locked,
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
@@ -595,7 +570,6 @@ 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))
@@ -726,7 +700,7 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = if (locked) null else ({ showRecurrencePicker = true }),
onClick = { showRecurrencePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -969,7 +943,7 @@ private fun EventEditContent(
}
if (showCalendarPicker) {
CalendarPicker(
CalendarPickerDialog(
calendars = state.calendars,
selectedId = form.calendarId,
onSelect = {
@@ -1738,18 +1712,26 @@ 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 = palette.map { it.argb },
colors = swatches,
selected = selected,
onSelect = { argb ->
palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.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()
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
colors = CALENDAR_COLOR_PALETTE,
selected = selected,
onSelect = onPickRaw,
dark = dark,
@@ -1884,7 +1866,6 @@ 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()
@@ -1898,7 +1879,6 @@ private fun InlineField(
textStyle = textStyle,
singleLine = singleLine,
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
)
}
@@ -1913,7 +1893,6 @@ private fun ScheduleRow(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
isError: Boolean = false,
enabled: Boolean = true,
) {
val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -1923,12 +1902,11 @@ 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; 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
// detail screen; errors flip them to the error colour.
val valueColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1945,7 +1923,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(enabled = enabled, onClick = onDateClick)
.clickable(onClick = onDateClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
@@ -1954,71 +1932,41 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(enabled = enabled, onClick = onTimeClick)
.clickable(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 CalendarPicker(
private fun CalendarPickerDialog(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Group by owning account (name, else type, else the calendar's own name),
// preserving the provider's order within and across groups — the same
// grouping [groupByAccount] applies for the drawer filter.
val groups = remember(calendars) {
calendars
.groupBy {
it.accountName.takeIf(String::isNotBlank)
?: it.accountType.takeIf(String::isNotBlank)
?: it.displayName
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,
)
}
}
.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) },
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}

View File

@@ -29,12 +29,6 @@ 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

View File

@@ -122,24 +122,18 @@ 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(
allCalendars,
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
}
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
@@ -171,33 +165,21 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs,
).flowOn(io),
colorPalette,
allCalendars,
) { local, external, palette, allCalendars ->
) { local, external, palette ->
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 = pickerCalendars,
calendars = external.writable,
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.
@@ -435,10 +417,7 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved
return
}
// 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) {
if (target != null && target.original.rrule != null) {
_saveState.value = SaveUiState.AwaitingScope
return
}

View File

@@ -7,11 +7,9 @@ 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
@@ -39,9 +37,7 @@ 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
@@ -57,7 +53,6 @@ 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
@@ -67,7 +62,6 @@ 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
@@ -82,7 +76,6 @@ 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
@@ -93,30 +86,24 @@ 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
@@ -135,17 +122,13 @@ 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, SpecialDates }
private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
/**
* Token-based accent for a leading icon chip (container / on-container pair).
@@ -209,19 +192,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
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 })
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
}
}
@@ -275,13 +246,6 @@ 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
@@ -534,18 +498,6 @@ 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),
@@ -582,24 +534,6 @@ 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),
@@ -691,28 +625,6 @@ 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),
@@ -800,7 +712,7 @@ private fun ViewsScreen(
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
@@ -814,7 +726,10 @@ private fun ViewsScreen(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
onCheckedChange = { on ->
val enabled = if (on) config.enabled + view else config.enabled - view
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
},
)
},
)
@@ -1010,7 +925,6 @@ private fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
@@ -1135,25 +1049,6 @@ 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).
@@ -1276,287 +1171,6 @@ 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)
@@ -1640,11 +1254,22 @@ 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)
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
// 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(", ")
}
/** Row summary for a calendar: its override, or the inherited global default. */
@@ -1707,152 +1332,6 @@ 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) }

View File

@@ -1,6 +1,5 @@
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
@@ -8,7 +7,6 @@ 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
@@ -77,30 +75,4 @@ 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,
)

View File

@@ -1,7 +1,6 @@
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
@@ -11,38 +10,23 @@ 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
@@ -52,16 +36,12 @@ 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() {
@@ -107,9 +87,8 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
combine(
prefs.defaultView,
@@ -154,7 +133,6 @@ class SettingsViewModel @Inject constructor(
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
)
}.stateIn(
scope = viewModelScope,
@@ -162,29 +140,6 @@ 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,
@@ -203,7 +158,6 @@ 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(
@@ -227,95 +181,6 @@ 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) }
}
@@ -324,35 +189,6 @@ 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)
@@ -426,26 +262,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
/**
* 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 setQuickSwitchConfig(config: QuickSwitchConfig) {
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
}
fun setDrawerViewOrder(order: List<CalendarView>) {

View File

@@ -1,129 +0,0 @@
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),
)
}

View File

@@ -5,7 +5,6 @@ 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
@@ -16,18 +15,15 @@ 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 overrides darkTheme, dynamicColor, and typography; the
* bare defaults (used by the crash screen) just follow the system.
* The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
typography: Typography = CalendulaTypography,
content: @Composable () -> Unit,
) {
val colorScheme = when {
@@ -46,7 +42,7 @@ fun CalendulaTheme(
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = typography,
typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content,
)

Binary file not shown.

View File

@@ -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 braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
@@ -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, Standartansicht, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, dynamische Farben, 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,27 +387,4 @@
<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>

View File

@@ -6,464 +6,4 @@
<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>

View File

@@ -6,454 +6,4 @@
<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>

View File

@@ -1,122 +0,0 @@
<?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>

View File

@@ -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 all it asks for up front — and nothing ever leaves your device.</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
<string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
@@ -68,8 +68,6 @@
<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>
@@ -276,15 +274,6 @@
<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>
@@ -362,35 +351,6 @@
<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 &amp; 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>
@@ -509,13 +469,4 @@
<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>

View File

@@ -10,6 +10,4 @@
<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>

View File

@@ -83,43 +83,6 @@ 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

View File

@@ -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,31 +120,9 @@ class EventDetailMapperTest {
}
@Test
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.
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
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()
assertThat(detail).isNull()
}
@Test

View File

@@ -1,15 +1,12 @@
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
@@ -86,8 +83,6 @@ 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. */
@@ -144,80 +139,6 @@ 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) {

View File

@@ -1,45 +0,0 @@
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()
}
}

View File

@@ -1,226 +0,0 @@
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)
}
}

View File

@@ -5,8 +5,6 @@ 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
@@ -200,22 +198,6 @@ 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))
@@ -337,26 +319,6 @@ 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))
@@ -440,19 +402,6 @@ 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))
@@ -466,59 +415,4 @@ 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))
}
}

View File

@@ -52,20 +52,4 @@ 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()
}
}

View File

@@ -1,129 +0,0 @@
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
}

View File

@@ -0,0 +1,214 @@
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,
)
}

View File

@@ -1,115 +0,0 @@
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()
}
}

View File

@@ -1,64 +0,0 @@
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()
}
}

View File

@@ -1,78 +0,0 @@
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")
}
}

View File

@@ -1,35 +0,0 @@
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)))
}
}

View File

@@ -1,61 +0,0 @@
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)
}
}

View File

@@ -1,248 +0,0 @@
# 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.

View File

@@ -10,12 +10,6 @@ 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.

View File

@@ -1,70 +0,0 @@
### 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]).

View File

@@ -1,16 +0,0 @@
### 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]).

View File

@@ -8,11 +8,6 @@ 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.

View File

@@ -1,92 +0,0 @@
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.

View File

@@ -1,93 +0,0 @@
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.

View File

@@ -1,93 +0,0 @@
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.

View File

@@ -1,17 +0,0 @@
# 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.