diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab8105..eff4ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,78 @@ 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.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, + 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]). + ## [2.12.0] — 2026-06-28 ### Added @@ -729,3 +801,12 @@ automatically, with zero telemetry and no internet permission. [#10]: https://codeberg.org/jlmakiola/calendula/issues/10 [#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 +[#29]: https://codeberg.org/jlmakiola/calendula/issues/29 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b20a171..6ff2520 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21200 - versionName = "2.12.0" + versionCode = 21300 + versionName = "2.13.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -90,6 +90,8 @@ android { buildFeatures { compose = true + // BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon). + buildConfig = true } // Don't embed AGP's dependency-metadata block in the APK signing block. It's diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fd9c2c8..8b1a5d3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -16,6 +16,18 @@ -keep class * extends androidx.room.RoomDatabase { *; } -dontwarn androidx.room.paging.** +# Glance runs an @Composable's `actionRunCallback()` by persisting the +# callback's fully-qualified class name into the click PendingIntent, then +# reflectively instantiating it (Class.forName(name).newInstance()) when the tap +# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only +# ever referenced reflectively — get renamed or have their no-arg constructor +# stripped, so the lookup fails silently and the tap does nothing. In the month +# and agenda widgets that broke every run-callback control (the prev/next/today +# month arrows and the agenda refresh) in release builds while actionStartActivity +# taps, which ride a PendingIntent and need no reflection, kept working. Keep +# every ActionCallback's name and constructor intact. +-keep class * implements androidx.glance.appwidget.action.ActionCallback { (...); } + # WorkManager instantiates an InputMerger reflectively (Class.newInstance) from # the fully-qualified class name persisted in the WorkSpec, so the class must # keep both its name and a no-arg constructor. Glance renders every widget diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml new file mode 100644 index 0000000..87de5a9 --- /dev/null +++ b/app/src/debug/res/values/colors.xml @@ -0,0 +1,10 @@ + + + + #FFB23B00 + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..5dad449 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Calendula Debug + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29f9c5e..8ad7750 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,15 @@ + + See all your events, beautifully - Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for. + 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. Grant calendar access Calendar access denied Calendula cannot show events without calendar access. You can grant it again in the system settings. @@ -68,6 +68,8 @@ Close Save Add title + + Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit. Starts Ends Ends before it starts @@ -274,6 +276,15 @@ Default view Dynamic colour Requires Android 12 or newer + Headings font + Body font + System default + Atkinson Hyperlegible + Lora + JetBrains Mono + Choose file… + Custom font + Couldn\'t read that file as a font Week starts on Automatic Time format @@ -308,6 +319,12 @@ %d day %d days + Views + Quick-switch button + Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. + Navigation menu + Drag to reorder the views listed in the navigation menu. + Drag to reorder New event form Fields shown by default — everything else sits behind \"More fields\" Focus title on new event @@ -324,7 +341,6 @@ None Use default reminder Amount - Custom (%1$s) Set Per-calendar reminders Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own. @@ -343,8 +359,38 @@ Add or improve a language on Weblate Theme, default view, week start + Quick-switch button and menu order Default fields for new events Event reminders + Contact birthdays & anniversaries + + + Contact special dates + Show contact dates + 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. + Birthdays + Anniversaries + Other dates + Title format + Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown). + Reminders + Show year + Include {year} in titles when it is known + Sync now + Not synced yet + + Last synced %1$s + Set each calendar\'s colour and visibility in Calendars settings. + Set in Contact special dates + Paused + Calendula can no longer read your contacts, so these calendars aren\'t updating. + Grant access + Turn off contact dates? + This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost. + + This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost. + Turn off + Save About License MIT @@ -463,4 +509,13 @@ _(The report was too long for this link — paste it from your clipboard here.)_ https://codeberg.org/jlmakiola/calendula/issues/new https://codeberg.org/jlmakiola/calendula/issues/new/choose + + + Birthdays + Anniversaries + Special dates + {name}\'s birthday ({year}) + {name}\'s anniversary ({year}) + {name} diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index f68afab..8171e19 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,4 +10,6 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 51083a5..7ebb69b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -83,6 +83,43 @@ class AllDayReminderEncodingTest { assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1) } + @Test + fun `next yearly occurrence wraps to next year once this year's date has passed`() { + val today = LocalDate.of(2026, 8, 1) + assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14)) + assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25)) + } + + @Test + fun `next yearly occurrence returns today when the date is today`() { + val today = LocalDate.of(2026, 3, 10) + assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today) + } + + @Test + fun `next yearly occurrence skips non-leap years for a Feb-29 date`() { + assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1))) + .isEqualTo(LocalDate.of(2028, 2, 29)) + } + + @Test + fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() { + // A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then, + // UTC+1). Sampling the offset there skews a modern CEST occurrence by an + // hour; sampling at the upcoming occurrence fires at the intended time. + val anchor1972 = LocalDate.of(1972, 7, 15) + val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST + val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm) + val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm) + + val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + val fireFromNext = actualFire(rawFromNext, nextOccurrence) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h + assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct + } + @Test fun `a winter-anchored offset drifts one hour on a summer occurrence`() { // Known limitation: one fixed MINUTES per series can't track DST. An diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 40aac33..f1be42b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -199,11 +199,12 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin") assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) - assertThat(values[CalendarContract.Events.DTEND]) - .isEqualTo(1_781_164_800_000L + 5_400_000L) - // A single occurrence never carries its own rule. + // The occurrence's length travels as DURATION, never DTEND — the provider + // rejects DTEND on an exception ("Exceptions can't overwrite dtend") and + // derives the end from DTSTART + DURATION, clearing the inherited RRULE. + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) - assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 900c9bc..f5a023a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -1,12 +1,15 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import java.time.ZoneId /** * Test-only fake. Tunable via the three `var` properties; `tick()` simulates @@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun deleteCalendar(id: Long) { writeError?.let { throw it } deletedCalendarIds += id + managedCalendarRows.removeAll { it.id == id } + managedEvents.remove(id) } /** All-day reminder fire-time minute-of-day passed into the last write. */ @@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun deleteEvent(eventId: Long) { writeError?.let { throw it } deletedEventIds += eventId + managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } } + } + + // --- Managed special-dates surface (stateful, so a re-sync sees inserts) --- + + var nextManagedEventId: Long = 1_000L + private var managedCalendarSeq: Long = 700L + private val managedCalendarRows = mutableListOf() + private val managedEvents = mutableMapOf>() + + data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType) + val createdManagedCalendars = mutableListOf() + val insertedManagedEvents = mutableListOf>() + val updatedManagedFields = mutableListOf>>() + + /** 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 = managedCalendarRows.toList() + + override fun queryManagedEvents(calendarId: Long): List = + 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>>() + + override fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) { + writeError?.let { throw it } + appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes) + } + + override fun updateManagedFields(eventId: Long, values: Map) { + 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) { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt new file mode 100644 index 0000000..dbfcff8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -0,0 +1,226 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.provider.CalendarContract +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow +import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SpecialDatesSyncEngineTest { + + private class FakeContacts( + var permission: Boolean = true, + var dates: List = 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 = 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 { + 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index eec1075..8fb656c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -5,7 +5,12 @@ 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 +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -149,6 +154,94 @@ class SettingsPrefsTest { assertThat(prefs.defaultFormFields.first()).isEmpty() } + @Test + fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + val config = prefs.quickSwitchConfig.first() + assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS) + assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + } + + @Test + fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + val config = QuickSwitchConfig( + order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day), + enabled = setOf(CalendarView.Agenda, CalendarView.Month), + ) + prefs.setQuickSwitchConfig(config) + val loaded = prefs.quickSwitchConfig.first() + assertThat(loaded.order).containsExactly( + CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day, + ).inOrder() + assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month) + assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder() + } + + @Test + fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + // Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent. + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram" + m + } + val config = prefs.quickSwitchConfig.first() + // Stored order first (Day, Week), then the omitted views in default order. + assertThat(config.order).containsExactly( + CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + ).inOrder() + // Day was explicitly disabled; the appended Month & Agenda default enabled. + assertThat(config.enabled).containsExactly( + CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + ) + } + + @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)) + assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + prefs.setDrawerViewOrder( + listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month), + ) + assertThat(prefs.drawerViewOrder.first()).containsExactly( + CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month, + ).inOrder() + } + + @Test + fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week" + m + } + assertThat(prefs.drawerViewOrder.first()).containsExactly( + CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day, + ).inOrder() + } + @Test fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) @@ -183,22 +276,38 @@ class SettingsPrefsTest { } @Test - fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest { + fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test - fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest { + fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setDefaultReminderMinutes(30) - assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30) - prefs.setDefaultReminderMinutes(null) - assertThat(prefs.defaultReminderMinutes.first()).isNull() + prefs.setDefaultReminderMinutes(listOf(30)) + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) + // Several lead times persist, normalised to distinct ascending order. + prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30)) + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder() + prefs.setDefaultReminderMinutes(emptyList()) + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test - fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest { + fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + // The pre-multi-reminder format stored a bare integer string. + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30" + m + } + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) + } + + @Test + fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) val prefs = SettingsPrefs(store) store.updateData { p -> @@ -206,7 +315,7 @@ class SettingsPrefsTest { m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m } - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test @@ -214,65 +323,85 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(newDataStore(tempDir)) assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) + prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080))) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) prefs.perCalendarReminderOverride.first().let { map -> - assertThat(map).containsExactly(7L, 15, 9L, null) + assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList()) } - // Inherit drops the override entirely (absent != null value). + // Inherit drops the override entirely (absent != an empty-list value). prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit) prefs.perCalendarReminderOverride.first().let { map -> - assertThat(map).containsExactly(7L, 15) + assertThat(map).containsExactly(7L, listOf(15, 10_080)) assertThat(map.containsKey(9L)).isFalse() } } + @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(), 9L, listOf(30), 11L, listOf(15), + ) + } + @Test fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() - prefs.setDefaultAllDayReminderMinutes(1_440) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440) - prefs.setDefaultAllDayReminderMinutes(null) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() + assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty() + prefs.setDefaultAllDayReminderMinutes(listOf(1_440)) + assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440) + prefs.setDefaultAllDayReminderMinutes(emptyList()) + assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty() } @Test fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) - prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) - assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) - assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) + prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15))) + prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440))) + assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15)) + assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440)) // Clearing the all-day override leaves the timed one untouched. prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() - assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) + assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15)) } @Test fun `resolveDefaultReminder picks the kind-matching override or global`() { - val timed = mapOf(7L to 15, 9L to null) - val allDay = mapOf(7L to 2_880) + val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList()) + val allDay = mapOf(7L to listOf(2_880)) fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( - timedGlobal = 30, - allDayGlobal = 1_440, + timedGlobal = listOf(30), + allDayGlobal = listOf(1_440), timedOverrides = timed, allDayOverrides = allDay, calendarId = calendarId, isAllDay = isAllDay, ) - // Timed: minutes override, explicit none, inherit global, no calendar. - assertThat(resolve(7L, isAllDay = false)).isEqualTo(15) - assertThat(resolve(9L, isAllDay = false)).isNull() - assertThat(resolve(5L, isAllDay = false)).isEqualTo(30) - assertThat(resolve(null, isAllDay = false)).isEqualTo(30) + // Timed: multi-value override, explicit none, inherit global, no calendar. + assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder() + assertThat(resolve(9L, isAllDay = false)).isEmpty() + assertThat(resolve(5L, isAllDay = false)).containsExactly(30) + assertThat(resolve(null, isAllDay = false)).containsExactly(30) // All-day: its own override wins; absent → all-day global; a timed-only // override (cal 9) does not bleed into all-day. - assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880) - assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440) - assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440) + assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880) + assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440) + assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440) } @Test @@ -311,6 +440,19 @@ class SettingsPrefsTest { assertThat(prefs.snoozeMinutes.first()).isEqualTo(1) } + @Test + fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + // Existing installs have no stamp key — that must read as 0. + assertThat(prefs.brandFontStamp.first()).isEqualTo(0) + assertThat(prefs.plainFontStamp.first()).isEqualTo(0) + prefs.bumpCustomFontStamp(FontRole.BRAND) + prefs.bumpCustomFontStamp(FontRole.BRAND) + prefs.bumpCustomFontStamp(FontRole.PLAIN) + assertThat(prefs.brandFontStamp.first()).isEqualTo(2) + assertThat(prefs.plainFontStamp.first()).isEqualTo(1) + } + @Test fun `explicit week-start prefs resolve regardless of locale`() { assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US)) @@ -324,4 +466,59 @@ class SettingsPrefsTest { assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) } + + @Test + fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesEnabled.first()).isFalse() + assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet()) + assertThat(prefs.specialDatesCalendars.first()).isEmpty() + assertThat(prefs.managedCalendarIds.first()).isEmpty() + assertThat(prefs.specialDatesShowYear.first()).isTrue() + } + + @Test + fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) + assertThat(prefs.specialDatesTypes.first()) + .isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom)) + } + + @Test + fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L) + prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L) + assertThat(prefs.specialDatesCalendars.first()) + .isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L)) + assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L)) + + prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null) + assertThat(prefs.specialDatesCalendars.first()) + .isEqualTo(mapOf(SpecialDateType.Anniversary to 9L)) + } + + @Test + fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty() + prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday") + assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]) + .isEqualTo("{name}'s birthday") + } + + @Test + fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesStatus.first()) + .isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null)) + + prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked) + assertThat(prefs.specialDatesStatus.first()) + .isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked)) + + prefs.recordSpecialDatesRun(2000L, null) + assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt new file mode 100644 index 0000000..5fedc7d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt @@ -0,0 +1,71 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class PostableAlertsTest { + + private fun alert(alertId: Long, calendarId: Long) = ReminderAlert( + alertId = alertId, + eventId = alertId * 10, + calendarId = calendarId, + beginMillis = 0L, + endMillis = 0L, + title = "Event $alertId", + location = null, + isAllDay = false, + ) + + @Test + fun `keeps alerts when no calendar is disabled`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200)) + + val postable = postableAlerts(due, disabledCalendarIds = emptySet()) + + assertThat(postable).isEqualTo(due) + } + + @Test + fun `drops alerts for a disabled calendar`() { + val keep = alert(1, calendarId = 100) + val drop = alert(2, calendarId = 200) + + val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200)) + + assertThat(postable).containsExactly(keep) + } + + @Test + fun `drops every alert when all their calendars are disabled`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100)) + + val postable = postableAlerts(due, disabledCalendarIds = setOf(100)) + + assertThat(postable).isEmpty() + } + + @Test + fun `keeps multiple alerts from the same enabled calendar`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100)) + + val postable = postableAlerts(due, disabledCalendarIds = setOf(999)) + + 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStoreTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStoreTest.kt new file mode 100644 index 0000000..74c5eda --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStoreTest.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SuppressedReminderStoreTest { + + private fun newDataStore(tempDir: Path): DataStore = + 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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt new file mode 100644 index 0000000..1914e0b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt @@ -0,0 +1,64 @@ +package de.jeanlucmakiola.calendula.domain.contacts + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ContactSpecialDateTest { + + @Test + fun `parses a full yyyy-MM-dd date with year`() { + val parts = parseContactEventDate("1990-05-14") + assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14)) + } + + @Test + fun `parses a year-less --MM-dd date`() { + val parts = parseContactEventDate("--05-14") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14)) + } + + @Test + fun `parses a compact yyyyMMdd date`() { + val parts = parseContactEventDate("19900514") + assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14)) + } + + @Test + fun `parses a year-less MM-dd date without the double-dash prefix`() { + val parts = parseContactEventDate("12-25") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25)) + } + + @Test + fun `keeps a year-less Feb 29 by validating against a leap anchor`() { + val parts = parseContactEventDate("--02-29") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29)) + } + + @Test + fun `keeps Feb 29 in a real leap year`() { + assertThat(parseContactEventDate("2000-02-29")) + .isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29)) + } + + @Test + fun `rejects Feb 29 in a non-leap year`() { + assertThat(parseContactEventDate("1999-02-29")).isNull() + } + + @Test + fun `rejects an impossible month or day`() { + assertThat(parseContactEventDate("2020-13-01")).isNull() + assertThat(parseContactEventDate("2020-06-31")).isNull() + assertThat(parseContactEventDate("--00-10")).isNull() + } + + @Test + fun `rejects blank, null and garbage input`() { + assertThat(parseContactEventDate(null)).isNull() + assertThat(parseContactEventDate("")).isNull() + assertThat(parseContactEventDate(" ")).isNull() + assertThat(parseContactEventDate("not-a-date")).isNull() + assertThat(parseContactEventDate("2020")).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt new file mode 100644 index 0000000..47bcb83 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -0,0 +1,78 @@ +package de.jeanlucmakiola.calendula.domain.contacts + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class SpecialDateFormattingTest { + + private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate( + lookupKey = "k", + displayName = "Jane", + type = SpecialDateType.Birthday, + month = month, + day = day, + year = year, + ) + + @Test + fun `uid is deterministic and namespaced by type`() { + assertThat(managedEventUid(SpecialDateType.Birthday, "abc")) + .isEqualTo("contact-birthday:abc@calendula") + assertThat(managedEventUid(SpecialDateType.Anniversary, "abc")) + .isEqualTo("contact-anniversary:abc@calendula") + } + + private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate( + lookupKey = "k", + displayName = "Jane", + type = SpecialDateType.Custom, + month = month, + day = day, + year = null, + label = label, + ) + + @Test + fun `birthday and anniversary uids ignore the discriminator`() { + assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula") + } + + @Test + fun `two custom dates on one contact get distinct uids by label`() { + assertThat(custom("Wedding", 6, 10).managedUid()) + .isEqualTo("contact-custom:k:wedding@calendula") + assertThat(custom("Graduation", 9, 1).managedUid()) + .isNotEqualTo(custom("Wedding", 6, 10).managedUid()) + } + + @Test + fun `label-less custom dates fall back to month-day so distinct dates survive`() { + assertThat(custom(null, 6, 10).managedUid()) + .isEqualTo("contact-custom:k:06-10@calendula") + assertThat(custom(null, 9, 1).managedUid()) + .isNotEqualTo(custom(null, 6, 10).managedUid()) + } + + @Test + fun `anchor date uses the known year`() { + assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14)) + } + + @Test + fun `anchor date falls back to a leap year when year-less`() { + assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29)) + } + + @Test + fun `title substitutes name and year`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990)) + .isEqualTo("Jane's birthday (1990)") + } + + @Test + fun `title drops an empty year parenthesis and tidies spacing`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null)) + .isEqualTo("Jane's birthday") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderOverrideForMinutesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderOverrideForMinutesTest.kt new file mode 100644 index 0000000..a900bd9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderOverrideForMinutesTest.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import org.junit.jupiter.api.Test + +/** + * The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is + * [CalendarReminderOverride.Minutes] (normalised), while clearing the last time + * reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an + * accidental toggle-undo can't silently wipe the calendar's default) and to + * explicit [CalendarReminderOverride.None] on the global default. + */ +class ReminderOverrideForMinutesTest { + + @Test + fun `empty selection on a per-calendar picker reverts to inherit`() { + assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true)) + .isEqualTo(CalendarReminderOverride.Inherit) + } + + @Test + fun `empty selection on the global default is explicit none`() { + assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false)) + .isEqualTo(CalendarReminderOverride.None) + } + + @Test + fun `a non-empty selection is normalised minutes regardless of scope`() { + assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true)) + .isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60))) + assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false)) + .isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60))) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt index 7c4f118..93d5028 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -91,4 +91,33 @@ class ViewBackStackTest { assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda)) .containsExactly(CalendarView.Agenda) } + + @Test + fun `next cycles through the configured views in order, wrapping around`() { + val cycle = listOf(CalendarView.Month, CalendarView.Agenda) + assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda) + // Wraps back to the first from the last. + assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month) + } + + @Test + fun `next from a view outside the cycle lands on the first enabled view`() { + // Day was disabled in the quick-switch cycle but is still the current view. + val cycle = listOf(CalendarView.Week, CalendarView.Month) + assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week) + } + + @Test + fun `next on an empty cycle stays put`() { + assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week) + } + + @Test + fun `quick-switch cycle keeps enabled views in their configured order`() { + val config = QuickSwitchConfig( + order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week), + enabled = setOf(CalendarView.Agenda, CalendarView.Month), + ) + assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt new file mode 100644 index 0000000..ab84941 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class FontsTest { + + @Test + fun `fromToken round-trips every bundled font`() { + BundledFont.entries.forEach { font -> + assertThat(BundledFont.fromToken(font.token)).isEqualTo(font) + } + } + + @Test + fun `fromToken is null for system, custom, and unknown tokens`() { + assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull() + assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull() + assertThat(BundledFont.fromToken("not-a-font")).isNull() + assertThat(BundledFont.fromToken(null)).isNull() + } + + @Test + fun `a bumped custom-font stamp breaks font-settings equality`() { + // Re-importing keeps the same "custom" token, so only the stamp can make + // the settings re-emit and invalidate remember() keys downstream. + val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1) + assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before) + assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before) + } + + @Test + fun `system for both roles returns the untouched default typography`() { + assertThat(calendulaTypography(brand = null, plain = null)) + .isSameInstanceAs(CalendulaTypography) + } + + @Test + fun `brand drives display and headline, plain drives title, body, and label`() { + val brand = BundledFont.Lora.family + val plain = BundledFont.JetBrainsMono.family + val typography = calendulaTypography(brand = brand, plain = plain) + + assertThat(typography.displayLarge.fontFamily).isEqualTo(brand) + assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand) + assertThat(typography.titleLarge.fontFamily).isEqualTo(plain) + assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain) + assertThat(typography.labelSmall.fontFamily).isEqualTo(plain) + } + + @Test + fun `a null role keeps that role's default family while the other is applied`() { + val plain = BundledFont.Lora.family + val typography = calendulaTypography(brand = null, plain = plain) + + // Brand styles keep the Material default; plain styles switch. + assertThat(typography.displayLarge.fontFamily) + .isEqualTo(CalendulaTypography.displayLarge.fontFamily) + assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain) + } +} diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md new file mode 100644 index 0000000..e92965d --- /dev/null +++ b/docs/design/contact-special-dates.md @@ -0,0 +1,248 @@ +# Design: Contact special-dates calendars (+ per-calendar multiple reminders) + +Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in) +Date: 2026-06-30 (implemented 2026-07-01) +Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite) + +Implementation notes / deviations from this design: + +- **Event identity key is a deterministic `Events.UID_2445`** + (`contact-:@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` (`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` 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` 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` + (`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. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 5055210..96ec0e3 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den Termin. +Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage +deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte +werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert — +Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll +kontrollierst. + Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend, mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen. diff --git a/fastlane/metadata/android/en-US/changelogs/21300.txt b/fastlane/metadata/android/en-US/changelogs/21300.txt new file mode 100644 index 0000000..0d7e7ac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21300.txt @@ -0,0 +1,70 @@ +### Added +- Calendula now speaks Spanish and Italian. Both arrived as community + translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/) + — a **huge thank you** to + [kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish + translation and to + [corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian + one! Pick your language under Settings → Language or in Android's per-app + language settings. Strings added in this very release may still show in + English until the translations catch up. Want Calendula in your language? + Translating happens entirely in the browser — every contribution is welcome. +- See your contacts' birthdays and anniversaries in your calendar. A new, + **optional** feature (Settings → Contact special dates) mirrors your contacts' + birthdays, anniversaries and other dates into local "Birthdays", + "Anniversaries" and "Other dates" calendars that stay in sync as your contacts + change. Each is a normal local calendar, so you set its colour, visibility and + reminders the usual way — new birthdays even start with a reminder a week before + *and* on the day. The title format is yours to customise (`{name}`, `{year}`). + This is the first feature to use the contacts permission: it is requested only + when you turn the feature on, everything stays on your device (Calendula has no + internet access), and your contacts are only ever read, never changed. Thanks + to @moonj for the suggestion ([#15]). +- Set more than one default reminder per calendar. A calendar's default + reminders — and the global defaults under Settings → Notifications — can now + hold several lead times instead of just one, so new events can start with, say, + a reminder a week before *and* one on the day. The reminder pickers are now + multi-select; per-calendar overrides can still inherit the global default or + turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]). +- Widget headers now open the app. Tapping the month/year title on the month + widget opens the app on the month view, and tapping the "Upcoming" title on + the agenda widget opens it on your default view — so there's a one-tap way + back into the app that lands where you'd expect, instead of only through a day + or event. On the month widget, tapping anywhere on a day — not just the small + date number — now opens that day, and the "today" button snaps the grid back + to the current month in place. Thanks to @rgz46vic and @ptab for the + suggestions ([#18], [#20]). +- Make Calendula's text your own. Settings → Appearance gains **Headings font** + and **Body font** pickers: keep the system default, choose a bundled face + (Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own + face), or load any `.ttf`/`.otf` file from your device, independently per + role. Font size and colour stay with Android's accessibility scaling and the + app theme, as discussed on the issue. Thanks to @abrossimow for the + suggestion ([#19]). +- Choose what the view-switch button cycles through. Settings → Appearance now + lets you pick which views the top-right quick-switch button rotates between + and drag them into your preferred order; views you switch off stay reachable + from the navigation menu, which can be reordered the same way. Thanks to + @abrossimow for the suggestion ([#24]). + +### Fixed +- Month widget arrows and "today" button work again. On release builds the + prev/next-month arrows and the jump-to-today control on the month widget did + nothing when tapped — code shrinking had stripped the tap handlers behind + them. They respond again. Thanks to @rgz46vic for the report ([#18]). +- Disabled calendars no longer notify. Reminders for events in a calendar you + have disabled (Settings → Calendars) are now suppressed instead of still + popping up — matching how a disabled calendar's events already stay hidden + everywhere else in the app ([#17]). +- Editing a single occurrence of a recurring event works again. Choosing **Only + this event** and saving a change to one event in a repeating series silently + did nothing — the change was rejected and the edit form simply reappeared with + nothing applied. The edited occurrence is now stored correctly, so the change + lands on just that one event and leaves the rest of the series untouched + ([#16]). +- Every calendar can be picked when creating an event. The event editor's + calendar picker was a fixed-height dialog, so with many calendars anything + past the first nine or so was simply unreachable. It is now a full-screen, + scrollable list grouped by account, with each calendar's colour shown. + Thanks to @dschuermann for the report ([#29]). + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index f59f242..2ebf536 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a simple repeat picker. Calendula also delivers your event reminders as notifications — tap one and you're on the event. +Optionally, show your contacts' birthdays, anniversaries and other special +dates in your calendar. You turn this on yourself; contacts are read only on +your device, are never uploaded, and are never changed — Calendula only +mirrors the dates into local calendars you fully control. + The differentiator is the design: real Material 3 Expressive throughout, with dynamic color, expressive motion, and expressive shapes. diff --git a/licenses/fonts/AtkinsonHyperlegible-OFL.txt b/licenses/fonts/AtkinsonHyperlegible-OFL.txt new file mode 100644 index 0000000..2befc89 --- /dev/null +++ b/licenses/fonts/AtkinsonHyperlegible-OFL.txt @@ -0,0 +1,92 @@ +Copyright 2020 Braille Institute of America, Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/JetBrainsMono-OFL.txt b/licenses/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/licenses/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/Lora-OFL.txt b/licenses/fonts/Lora-OFL.txt new file mode 100644 index 0000000..d0ce7fe --- /dev/null +++ b/licenses/fonts/Lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/README.md b/licenses/fonts/README.md new file mode 100644 index 0000000..d413db2 --- /dev/null +++ b/licenses/fonts/README.md @@ -0,0 +1,17 @@ +# Bundled fonts + +Calendula bundles the following fonts as optional user-selectable typefaces +(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font +License 1.1; the full license text is included alongside this file. + +| Font | Files (`app/src/main/res/font/`) | Copyright | License | +|------|----------------------------------|-----------|---------| +| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) | +| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) | +| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) | + +The device's default typeface (Roboto on most Android builds) remains the +default and is not bundled. + +Fonts a user loads from their own device via "Choose file…" are copied into the +app's private storage and are not redistributed.