diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab8105..ab6a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- 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]). + +### 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]). + ## [2.12.0] — 2026-06-28 ### Added @@ -729,3 +763,8 @@ 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 +[#16]: https://codeberg.org/jlmakiola/calendula/issues/16 +[#17]: https://codeberg.org/jlmakiola/calendula/issues/17 +[#18]: https://codeberg.org/jlmakiola/calendula/issues/18 +[#20]: https://codeberg.org/jlmakiola/calendula/issues/20 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b20a171..72977e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index a2de42c..f3fe318 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -29,11 +30,15 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.DebugRibbon import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -97,23 +102,41 @@ class MainActivity : AppCompatActivity() { val use24Hour = remember(settings.timeFormat, context) { settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) } + // The user's custom-font choice, resolved to a Material typography + // (issue #19). Recomputed only when a token — or the custom-font + // re-import stamp AppFontSettings carries, so replacing the file + // behind an active "custom" token still refreshes — changes; + // "system for both" returns the default scale untouched. + val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() + val typography = remember(fonts, context) { + calendulaTypography( + brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context), + plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context), + ) + } CalendulaTheme( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, + typography = typography, ) { - CompositionLocalProvider( - LocalUse24HourFormat provides use24Hour, - LocalShowHourLines provides settings.showHourLines, - ) { - RootScreen( - modifier = Modifier.fillMaxSize(), - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = { requestedDetailKey = null }, - widgetNavRequest = requestedNav, - onWidgetNavConsumed = { requestedNav = null }, - requestedImportUri = requestedImportUri, - onImportConsumed = { requestedImportUri = null }, - ) + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider( + LocalUse24HourFormat provides use24Hour, + LocalShowHourLines provides settings.showHourLines, + ) { + RootScreen( + modifier = Modifier.fillMaxSize(), + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = { requestedDetailKey = null }, + widgetNavRequest = requestedNav, + onWidgetNavConsumed = { requestedNav = null }, + requestedImportUri = requestedImportUri, + onImportConsumed = { requestedImportUri = null }, + ) + } + // A persistent corner marker so a debug build is never + // mistaken for the production app; compiled out of release. + if (BuildConfig.DEBUG) DebugRibbon() } pendingCrashReport?.let { report -> CrashReportDialog( @@ -186,6 +209,12 @@ class MainActivity : AppCompatActivity() { // An external date tap (launcher/clock) has no widget source, so it opens // the day view rooted over the default home view (OpenDate source = null). calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) } + // A widget header tap: open a top-level view with no date drill-in. The + // empty string carried by [openViewIntent] means "the default home view". + if (hasExtra(EXTRA_OPEN_VIEW)) { + val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty() + return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name }) + } val source = sourceViewOrNull() val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) return when { @@ -237,6 +266,10 @@ class MainActivity : AppCompatActivity() { private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO" private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE" + // A widget header tap asking to open a top-level view (no date drill-in). + // Its value is the target [CalendarView] name, or "" for the default view. + private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW" + // The [CalendarView] (by name) of the widget a launch came from. Roots the // in-app back stack in that view; absent for non-widget launches (reminders). private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW" @@ -300,5 +333,19 @@ class MainActivity : AppCompatActivity() { putExtra(EXTRA_DATE_ISO, date.toString()) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + + /** + * Open the app on a top-level [view] with no date drill-in — a widget + * header tap. A null [view] opens the user's default home view (the agenda + * widget's "Upcoming" title); a concrete view roots there over the default + * home (the month widget's month/year title → [CalendarView.Month]). The + * per-view data URI keeps distinct headers' PendingIntents from collapsing. + */ + fun openViewIntent(context: Context, view: CalendarView?): Intent = + Intent(context, MainActivity::class.java).apply { + data = "calendula://view/${view?.name ?: "default"}".toUri() + putExtra(EXTRA_OPEN_VIEW, view?.name ?: "") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 0f50930..2c3928c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -118,8 +118,14 @@ internal fun buildEventUpdateValues( * provider clone the series row and apply these on top. Unlike the series * update there is no dirty check — the exception is a fresh row, so every * form-backed column is written (empty optionals as explicit NULLs, since the - * clone starts from the parent's values). An exception is a single event: - * DTEND, never RRULE/DURATION. + * clone starts from the parent's values). + * + * The occurrence's length travels as DURATION, never DTEND: the provider + * rejects DTEND on an exception outright (`CalendarProvider2`: + * "Exceptions can't overwrite dtend") and derives the instance end from + * DTSTART + DURATION itself, clearing the inherited RRULE in the process. This + * matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made + * "only this event" fail on-device (Codeberg #16). */ internal fun buildOccurrenceExceptionValues( form: EventForm, @@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.DTSTART, times.dtStartMillis) - put(CalendarContract.Events.DTEND, times.dtEndMillis) + put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt new file mode 100644 index 0000000..d27ec99 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.data.fonts + +import android.content.Context +import android.net.Uri +import de.jeanlucmakiola.calendula.domain.FontRole +import java.io.File +import java.util.Locale +import android.graphics.fonts.Font as PlatformFont + +/** + * Storage for user-loaded custom fonts (issue #19). A font a user picks via the + * system file picker is copied into the app's private storage — one file per + * [FontRole] — so the selection survives even if the original is later moved or + * deleted, and never leaves the app. The chosen file is validated as a real font + * before it replaces the previous one, so a bad pick can't wedge the app-wide + * typography. + */ +object CustomFontStore { + + private fun dir(context: Context): File = File(context.filesDir, "fonts") + + /** The stored font file for [role] (may not exist yet). */ + fun file(context: Context, role: FontRole): File = + File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf") + + fun exists(context: Context, role: FontRole): Boolean = + file(context, role).let { it.exists() && it.length() > 0 } + + /** + * Copy [uri] into per-role storage, first validating that it parses as a + * font. Returns true on success; on any failure the previous file is left + * untouched and false is returned (the caller keeps the old selection). + */ + fun import(context: Context, role: FontRole, uri: Uri): Boolean { + val target = file(context, role) + target.parentFile?.mkdirs() + val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp") + return try { + context.contentResolver.openInputStream(uri)?.use { input -> + tmp.outputStream().use { output -> input.copyTo(output) } + } ?: return false + // Font.Builder throws IOException for anything that isn't a valid + // font file (API 29+, which is our minSdk) — a cheap, reliable check. + PlatformFont.Builder(tmp).build() + if (target.exists() && !target.delete()) return false + tmp.renameTo(target) + } catch (_: Exception) { + tmp.delete() + false + } + } + + /** Forget the custom font for [role] (used when switching away from it). */ + fun clear(context: Context, role: FontRole) { + file(context, role).delete() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 1a1f1d5..657d505 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -8,10 +8,14 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -104,6 +108,47 @@ class SettingsPrefs @Inject constructor( store.edit { it[DYNAMIC_COLOR_KEY] = enabled } } + /** + * Custom-font tokens per Material typeface role (issue #19). Stored as opaque + * strings — "system", "custom", or a bundled font's token — resolved to a + * FontFamily at the theme layer; an unknown token degrades to the default. + */ + val brandFont: Flow = store.data.map { prefs -> + prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN + } + + val plainFont: Flow = store.data.map { prefs -> + prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN + } + + suspend fun setBrandFont(token: String) { + store.edit { it[BRAND_FONT_KEY] = token } + } + + suspend fun setPlainFont(token: String) { + store.edit { it[PLAIN_FONT_KEY] = token } + } + + /** + * A per-role bump counter for the user-loaded custom font. Re-importing + * overwrites the same file under the same "custom" token, so the token alone + * can't signal the change; this stamp — carried into AppFontSettings — breaks + * value equality so the resolved FontFamily (and its previews) refresh. A + * missing key is 0 (existing installs); the first import bumps it to 1. + */ + val brandFontStamp: Flow = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 } + + val plainFontStamp: Flow = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 } + + /** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */ + suspend fun bumpCustomFontStamp(role: FontRole) { + val key = when (role) { + FontRole.BRAND -> BRAND_FONT_STAMP_KEY + FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY + } + store.edit { it[key] = (it[key] ?: 0) + 1 } + } + suspend fun setWeekStart(pref: WeekStartPref) { store.edit { it[WEEK_START_KEY] = pref.storageValue() } } @@ -202,6 +247,47 @@ class SettingsPrefs @Inject constructor( store.edit { it[DEFAULT_VIEW_KEY] = view.name } } + /** + * Quick-switch button customisation (#24): the ordered full view list plus + * which views are enabled in the cycle. Stored comma-joined by enum name, a + * "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing + * views are appended enabled and unknown names dropped, so a future view + * defaults into the cycle. An absent key means [QuickSwitchConfig.Default]. + */ + val quickSwitchConfig: Flow = store.data.map { prefs -> + parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY]) + } + + suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) { + store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) } + } + + /** + * Atomic read-modify-write of the quick-switch config: [transform] sees the + * value currently stored, parsed inside the edit block, not the async-echoed + * UI snapshot. Two rapid intents (a toggle then a drag) therefore compose + * instead of each re-serialising a stale copy of the other field over it. + */ + suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) { + store.edit { prefs -> + val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY]) + prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current)) + } + } + + /** + * Navigation-drawer view order (#24). Comma-joined enum names; missing views + * are appended in default order and unknown names dropped. Absent key means + * [IMPLEMENTED_VIEWS] (the historical fixed order). + */ + val drawerViewOrder: Flow> = store.data.map { prefs -> + parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY]) + } + + suspend fun setDrawerViewOrder(order: List) { + store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } } + } + /** * Optional event-form fields shown by default (the rest hide behind * "more fields"). Stored comma-joined by enum name: an absent key means @@ -276,35 +362,36 @@ class SettingsPrefs @Inject constructor( } /** - * The default reminder lead time (minutes before start) prefilled on new - * **timed** events. `null` = no default reminder — the prior behaviour, kept - * as the factory default so existing users aren't surprised by reminders they - * never asked for. Stored as a string so "none" is distinct from a numeric - * value (and from an unset key, which is also "none"). Per-calendar overrides - * in [perCalendarReminderOverride] take precedence; all-day events instead use + * The default reminder lead times (minutes before start) prefilled on new + * **timed** events. The empty list = no default reminder — the prior + * behaviour, kept as the factory default so existing users aren't surprised by + * reminders they never asked for. Stored as a comma-joined list of minutes, or + * "none"/empty for no reminder (also the unset state). A legacy single value + * ("30") parses transparently to a one-element list. Per-calendar overrides in + * [perCalendarReminderOverride] take precedence; all-day events instead use * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. */ - val defaultReminderMinutes: Flow = store.data.map { prefs -> - prefs[DEFAULT_REMINDER_KEY].toReminderMinutes() + val defaultReminderMinutes: Flow> = store.data.map { prefs -> + prefs[DEFAULT_REMINDER_KEY].toReminderList() } - suspend fun setDefaultReminderMinutes(minutes: Int?) { - store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE } + suspend fun setDefaultReminderMinutes(minutes: List) { + store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() } } /** - * The default reminder lead time prefilled on new **all-day** events, in + * The default reminder lead times prefilled on new **all-day** events, in * minutes before the start of the day. All-day events want day-scale lead * times ("1 day before"), so they have their own default rather than reusing - * the timed one. `null` = no default. Per-calendar overrides do **not** apply - * to all-day events — they always use this global value. + * the timed one. Empty list = no default. Per-calendar overrides do **not** + * apply to all-day events — they always use this global value. */ - val defaultAllDayReminderMinutes: Flow = store.data.map { prefs -> - prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes() + val defaultAllDayReminderMinutes: Flow> = store.data.map { prefs -> + prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList() } - suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) { - store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE } + suspend fun setDefaultAllDayReminderMinutes(minutes: List) { + store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() } } /** @@ -341,13 +428,13 @@ class SettingsPrefs @Inject constructor( /** * Per-calendar overrides of [defaultReminderMinutes] for **timed** events, * keyed by calendar id. A calendar **present** in the map overrides the global - * timed default for its new events: a `null` value means "no reminder", an int - * means that lead time. A calendar **absent** from the map inherits the global - * default. Serialised as `id=value;id=value`, with `none` for an explicit - * no-reminder override. (All-day events ignore this and use - * [defaultAllDayReminderMinutes].) + * timed default for its new events: an **empty** list means "no reminder", a + * non-empty list means those lead times. A calendar **absent** from the map + * inherits the global default. Serialised as `id=value;id=value`, where a value + * is a comma-joined minute list or `none` for an explicit no-reminder override. + * (All-day events ignore this and use [defaultAllDayReminderMinutes].) */ - val perCalendarReminderOverride: Flow> = store.data.map { prefs -> + val perCalendarReminderOverride: Flow>> = store.data.map { prefs -> parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) } @@ -364,7 +451,7 @@ class SettingsPrefs @Inject constructor( * events, with the same semantics as [perCalendarReminderOverride] (absent = * inherit the global all-day default; present null = no reminder). */ - val perCalendarAllDayReminderOverride: Flow> = store.data.map { prefs -> + val perCalendarAllDayReminderOverride: Flow>> = store.data.map { prefs -> parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) } @@ -433,9 +520,47 @@ class SettingsPrefs @Inject constructor( .toSet() } + /** Parse a plain comma-joined view order, completed to every implemented view. */ + private fun parseViewOrder(stored: String?): List = + completeViewOrder( + stored?.split(',').orEmpty() + .mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } }, + ) + + /** Parse the quick-switch config; "!"-prefixed names are disabled. */ + private fun parseQuickSwitch(stored: String?): QuickSwitchConfig { + if (stored == null) return QuickSwitchConfig.Default + val parsed = stored.split(',').mapNotNull { raw -> + val token = raw.trim() + val disabled = token.startsWith("!") + val name = if (disabled) token.drop(1) else token + CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled } + } + val order = completeViewOrder(parsed.map { it.first }) + // Only views explicitly stored disabled are excluded; anything appended + // (a view added in a later release) defaults into the cycle. + val disabled = parsed.filter { it.second }.map { it.first }.toSet() + return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet()) + } + + private fun serializeQuickSwitch(config: QuickSwitchConfig): String = + completeViewOrder(config.order).joinToString(",") { view -> + if (view in config.enabled) view.name else "!${view.name}" + } + + /** Keep the given order (de-duplicated), then append any views it omits. */ + private fun completeViewOrder(seen: List): List { + val ordered = seen.distinct() + return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered } + } + companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") + internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") + internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") + internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") + internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp") internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") @@ -445,6 +570,8 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") + internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") + internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") @@ -495,43 +622,46 @@ sealed interface CalendarReminderOverride { data object Inherit : CalendarReminderOverride /** Explicit "no reminder" for this calendar, regardless of the global default. */ data object None : CalendarReminderOverride - /** A specific lead time in minutes before the event start. */ - data class Minutes(val minutes: Int) : CalendarReminderOverride + /** Specific lead times in minutes before the event start (non-empty). */ + data class Minutes(val minutes: List) : CalendarReminderOverride } /** - * The lead time to prefill on a new event: the matching per-calendar override + * The lead times to prefill on a new event: the matching per-calendar override * if [calendarId] has one for this event kind, otherwise the global default for * that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed - * events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so - * it can be unit-tested. + * events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder. + * Pure so it can be unit-tested. */ fun resolveDefaultReminder( - timedGlobal: Int?, - allDayGlobal: Int?, - timedOverrides: Map, - allDayOverrides: Map, + timedGlobal: List, + allDayGlobal: List, + timedOverrides: Map>, + allDayOverrides: Map>, calendarId: Long?, isAllDay: Boolean, -): Int? { +): List { val overrides = if (isAllDay) allDayOverrides else timedOverrides val global = if (isAllDay) allDayGlobal else timedGlobal return if (calendarId != null && overrides.containsKey(calendarId)) { - overrides[calendarId] + overrides.getValue(calendarId) } else { global } } -/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */ -private fun MutableMap.applyOverride( +/** + * Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the + * key; [None] and an empty [Minutes] both store the empty list). + */ +private fun MutableMap>.applyOverride( calendarId: Long, override: CalendarReminderOverride, ) { when (override) { CalendarReminderOverride.Inherit -> remove(calendarId) - CalendarReminderOverride.None -> put(calendarId, null) - is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) + CalendarReminderOverride.None -> put(calendarId, emptyList()) + is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders()) } } @@ -557,24 +687,44 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) { private const val NONE = "none" private const val ENTRY_SEP = ";" private const val KEY_VALUE_SEP = "=" +private const val LIST_SEP = "," -private fun String?.toReminderMinutes(): Int? = when (this) { - null, "", NONE -> null - else -> toIntOrNull() +/** Distinct, ascending lead times — the canonical form stored and resolved. */ +private fun List.normalizeReminders(): List = distinct().sorted() + +/** + * Parse a stored reminder value into lead times. `null`/empty/"none" → empty + * list; a comma-joined list → its minutes; a legacy single value ("30") → a + * one-element list. Non-numeric parts are dropped defensively. + */ +private fun String?.toReminderList(): List = when { + this == null || isEmpty() || this == NONE -> emptyList() + else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders() } -private fun parseReminderOverrides(stored: String?): Map { +/** Serialise lead times for storage: "none" when empty, else comma-joined. */ +private fun List.toStoredReminders(): String = + if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() } + +private fun parseReminderOverrides(stored: String?): Map> { if (stored.isNullOrBlank()) return emptyMap() return stored.split(ENTRY_SEP).mapNotNull { entry -> val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null val id = parts[0].toLongOrNull() ?: return@mapNotNull null - val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null - id to value + // Only the deliberate "none" sentinel means an explicit no-reminder + // override (empty list); a non-sentinel value that parses to no valid + // minutes is garbage and drops the entry, so the calendar inherits the + // global default rather than silently reading as "no reminder". + when (val value = parts[1]) { + NONE -> id to emptyList() + else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it } + ?: return@mapNotNull null + } }.toMap() } -private fun serializeReminderOverrides(map: Map): String = - map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } +private fun serializeReminderOverrides(map: Map>): String = + map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" } private inline fun > String?.toEnum(default: E): E = this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt index 71af6a3..22fc5cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt @@ -8,6 +8,7 @@ import android.content.pm.PackageManager import android.provider.CalendarContract import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -16,6 +17,29 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +/** + * True when [this] alert belongs to a calendar the user disabled in-app, so its + * reminder must be suppressed (mirroring the event filtering in + * CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a + * pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are + * never treated as disabled. This is the one predicate the disabled-calendar + * gate is built from: [postableAlerts] here and the choke point in + * [ReminderNotifier.post] both use it. + */ +internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set): Boolean = + calendarId != 0L && calendarId in disabledCalendarIds + +/** + * The due alerts that should actually surface as notifications: everything + * except alerts whose calendar the user has disabled in-app. The caller still + * marks the full due set fired, so suppressed alerts are not re-broadcast by the + * provider. + */ +internal fun postableAlerts( + due: List, + disabledCalendarIds: Set, +): List = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) } + /** * Becomes the app that turns the calendar provider's reminder alarms into * visible notifications (the Etar model — the provider broadcasts @@ -32,6 +56,8 @@ class EventReminderReceiver : BroadcastReceiver() { @Inject lateinit var alertStore: ReminderAlertStore @Inject lateinit var notifier: ReminderNotifier @Inject lateinit var settingsPrefs: SettingsPrefs + @Inject lateinit var calendarPrefs: CalendarPrefs + @Inject lateinit var suppressedStore: SuppressedReminderStore override fun onReceive(context: Context, intent: Intent) { if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return @@ -46,8 +72,17 @@ class EventReminderReceiver : BroadcastReceiver() { if (settingsPrefs.remindersEnabled.first()) { val now = System.currentTimeMillis() val due = alertStore.dueAlerts(now) - due.forEach { notifier.post(it) } + val disabled = calendarPrefs.disabledCalendarIds.first() + val postable = postableAlerts(due, disabled) + // Suppress reminders for disabled calendars, but still mark + // every due alert fired so the provider stops re-broadcasting + // the suppressed ones. Stash those suppressed alerts so + // re-enabling their calendar can recover them (they would + // otherwise stay STATE_FIRED forever with no re-scan). + postable.forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) + suppressedStore.stash(due - postable.toSet(), now) + suppressedStore.purgeExpired(now) } } finally { pendingResult.finish() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index b0a57fc..5a25f8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() { private const val EXTRA_ALERT_ID = "alert_id" private const val EXTRA_EVENT_ID = "event_id" + private const val EXTRA_CALENDAR_ID = "calendar_id" private const val EXTRA_BEGIN = "begin" private const val EXTRA_END = "end" private const val EXTRA_TITLE = "title" @@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() { this.action = action putExtra(EXTRA_ALERT_ID, alert.alertId) putExtra(EXTRA_EVENT_ID, alert.eventId) + putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_END, alert.endMillis) putExtra(EXTRA_TITLE, alert.title) @@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() { return ReminderAlert( alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), + calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), endMillis = intent.getLongExtra(EXTRA_END, 0L), title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt index 5d14579..54f68dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt @@ -16,6 +16,7 @@ import javax.inject.Singleton data class ReminderAlert( val alertId: Long, val eventId: Long, + val calendarId: Long, val beginMillis: Long, val endMillis: Long, /** Raw event title; may be blank — the notifier substitutes "(no title)". */ @@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor( ReminderAlert( alertId = c.getLong(0), eventId = c.getLong(1), - beginMillis = c.getLong(2), - endMillis = c.getLong(3), - title = c.getString(4).orEmpty(), - location = c.getString(5)?.takeIf { it.isNotBlank() }, - isAllDay = c.getInt(6) == 1, + calendarId = c.getLong(2), + beginMillis = c.getLong(3), + endMillis = c.getLong(4), + title = c.getString(5).orEmpty(), + location = c.getString(6)?.takeIf { it.isNotBlank() }, + isAllDay = c.getInt(7) == 1, ), ) } @@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor( val PROJECTION = arrayOf( CalendarContract.CalendarAlerts._ID, CalendarContract.CalendarAlerts.EVENT_ID, + CalendarContract.CalendarAlerts.CALENDAR_ID, CalendarContract.CalendarAlerts.BEGIN, CalendarContract.CalendarAlerts.END, CalendarContract.CalendarAlerts.TITLE, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 7b92a9d..9bd0dde 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.is24Hour import kotlinx.coroutines.flow.first @@ -33,6 +34,7 @@ import javax.inject.Singleton class ReminderNotifier @Inject constructor( @ApplicationContext private val context: Context, private val settingsPrefs: SettingsPrefs, + private val calendarPrefs: CalendarPrefs, ) { /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ @@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor( } suspend fun post(alert: ReminderAlert) { + // The single choke point for the disabled-calendar gate: it covers both + // the provider broadcast (EventReminderReceiver) and a snoozed re-show + // (ReminderActionReceiver), so a calendar disabled after a snooze no + // longer notifies — without either receiver duplicating the check. + if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return ensureChannel() val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val is24Hour = settingsPrefs.timeFormat.first() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStore.kt new file mode 100644 index 0000000..eb5b6e0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/SuppressedReminderStore.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Still relevant while the event has not ended: a reminder for an event that is + * already over is pointless to re-surface. Falls back to the begin time when the + * end is unknown (0L). Used both to decide what to re-post and to purge the stash. + */ +internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean = + (endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis + +/** + * Local stash of reminder alerts that fired while their calendar was disabled + * in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless + * (so the provider stops re-broadcasting the suppressed ones), which would + * otherwise lose those reminders forever — there is no re-scan. Stashing lets + * [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if + * the user re-enables the calendar before the event is over. + * + * Persisted in the shared preferences DataStore as a set of self-describing + * strings (one per alert); the stash never reaches a screen, so there is no + * domain model. Entries whose event has already ended are dropped on the next + * stash/recover/purge, so the stash only ever holds a handful of pending alerts. + */ +@Singleton +class SuppressedReminderStore @Inject constructor( + private val store: DataStore, +) { + + /** Add [alerts] to the stash, replacing any existing entry with the same id. */ + suspend fun stash(alerts: List, nowMillis: Long) { + if (alerts.isEmpty()) return + store.edit { prefs -> + val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId } + alerts.forEach { byId[it.alertId] = it } + prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) }) + } + } + + /** + * Remove and return the still-relevant stashed alerts belonging to any of + * [calendarIds]; drops expired entries for every calendar in passing. + */ + suspend fun recoverFor(calendarIds: Set, nowMillis: Long): List { + val recovered = mutableListOf() + store.edit { prefs -> + val kept = decodeAll(prefs).filter { alert -> + when { + !alert.isRelevantAt(nowMillis) -> false // expired: drop + alert.calendarId in calendarIds -> { recovered += alert; false } + else -> true + } + } + prefs.putStash(kept) + } + return recovered + } + + /** Drop entries whose event has already ended — cheap opportunistic cleanup. */ + suspend fun purgeExpired(nowMillis: Long) { + store.edit { prefs -> + prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) }) + } + } + + private fun decodeAll(prefs: Preferences): List = + prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) } + + private fun MutablePreferences.putStash(alerts: List) { + val encoded = alerts.map { encodeStashEntry(it) }.toSet() + if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded) + } + + private companion object { + val KEY = stringSetPreferencesKey("suppressed_reminders") + } +} + +// One stash entry as a delimited string. The '|' separator is safe because every +// free-text field is Base64-encoded first (that alphabet never contains '|'), and +// a null location is stored as a distinct sentinel that Base64 also never yields. +private const val FIELD_SEP = "|" +private const val NULL_LOCATION = "-" + +internal fun encodeStashEntry(alert: ReminderAlert): String = listOf( + alert.alertId.toString(), + alert.eventId.toString(), + alert.calendarId.toString(), + alert.beginMillis.toString(), + alert.endMillis.toString(), + if (alert.isAllDay) "1" else "0", + alert.title.toBase64(), + alert.location?.toBase64() ?: NULL_LOCATION, +).joinToString(FIELD_SEP) + +/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */ +internal fun decodeStashEntry(raw: String): ReminderAlert? { + val parts = raw.split(FIELD_SEP) + if (parts.size != 8) return null + return try { + ReminderAlert( + alertId = parts[0].toLong(), + eventId = parts[1].toLong(), + calendarId = parts[2].toLong(), + beginMillis = parts[3].toLong(), + endMillis = parts[4].toLong(), + title = parts[6].fromBase64(), + location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(), + isAllDay = parts[5] == "1", + ) + } catch (e: NumberFormatException) { + null + } catch (e: IllegalArgumentException) { // bad Base64 + null + } +} + +private fun String.toBase64(): String = + Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8)) + +private fun String.fromBase64(): String = + String(Base64.getDecoder().decode(this), Charsets.UTF_8) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt new file mode 100644 index 0000000..92ddc93 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt @@ -0,0 +1,8 @@ +package de.jeanlucmakiola.calendula.domain + +/** + * The two Material 3 typeface roles a user can set independently (issue #19): + * [BRAND] drives the display/headline styles (expression), [PLAIN] the + * title/body/label styles (readability). Each defaults to the system typeface. + */ +enum class FontRole { BRAND, PLAIN } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 50ee186..612840e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -79,6 +79,11 @@ fun CalendarHost( // correcting it. Brief blank first frame, matching the onboarding gate above. val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return + // View customisation (#24): the quick-switch cycle and drawer order. Both have + // sensible non-empty initial values, so they're ready before the first frame. + val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value + val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value + var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } @@ -212,6 +217,17 @@ fun CalendarHost( detailKey = key onWidgetNavConsumed() } + is WidgetNavRequest.OpenView -> { + // A widget header tap: land on a top-level view with no date + // drill-in. Reveal it by dropping any covering overlay, then root + // the stack on the target (null → the default home) over the + // default home — so backing out returns to the default, then exits. + dismissCoveringOverlays() + createDateIso = null + pendingDayIso = null + viewStack = viewBaseStack(defaultView, req.view ?: defaultView) + onWidgetNavConsumed() + } is WidgetNavRequest.Create -> { // External "new event" entries (QS tile / launcher shortcut / // widget) must land on top of whatever is open — the form overlay @@ -260,6 +276,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Day -> DayScreen( selectedView = currentView, @@ -269,6 +287,8 @@ fun CalendarHost( onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Month -> MonthScreen( selectedView = currentView, @@ -277,6 +297,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Agenda -> AgendaScreen( selectedView = currentView, @@ -285,6 +307,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 9a9c2b7..773f76d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L), initialValue = null, ) + + /** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */ + val quickSwitchViews: StateFlow> = prefs.quickSwitchConfig + .map { it.cycle } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = IMPLEMENTED_VIEWS, + ) + + /** Order of the views in the navigation drawer (#24); every view always shown. */ + val drawerViewOrder: StateFlow> = prefs.drawerViewOrder + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = IMPLEMENTED_VIEWS, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt index 6588d10..985806b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt @@ -31,4 +31,13 @@ sealed interface WidgetNavRequest { /** Open the create-event form prefilled for [dateIso] (today when null). */ data class Create(val dateIso: String?) : WidgetNavRequest + + /** + * Open the app rooted on a top-level [view] with no date drill-in — a widget + * header tap. A null [view] means "the user's default home view" (the agenda + * widget's "Upcoming" title, issue #20); a concrete view roots there over the + * default home (the month widget's month/year title → [CalendarView.Month], + * issue #18), so backing out returns to the default view, then exits. + */ + data class OpenView(val view: CalendarView?) : WidgetNavRequest } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 4db125c..8286ee8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.Position @@ -96,6 +97,8 @@ fun AgendaScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { @@ -120,6 +123,7 @@ fun AgendaScreen( CalendarDrawer( currentView = selectedView, currentDate = anchor, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -140,7 +144,7 @@ fun AgendaScreen( topBar = { AgendaTopBar( selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index d56ee9a..bfadaf6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier +import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor( private val icsExporter: IcsExporter, private val prefs: CalendarPrefs, private val settingsPrefs: SettingsPrefs, + private val suppressedStore: SuppressedReminderStore, + private val notifier: ReminderNotifier, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor( viewModelScope.launch { val current = prefs.disabledCalendarIds.first() val next = if (disabled) current + id else current - id - if (next != current) prefs.setDisabledCalendarIds(next) + if (next != current) { + prefs.setDisabledCalendarIds(next) + if (!disabled) recoverReminders(setOf(id)) + } } } @@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor( viewModelScope.launch { val current = prefs.disabledCalendarIds.first() val next = if (disabled) current + ids else current - ids.toSet() - if (next != current) prefs.setDisabledCalendarIds(next) + if (next != current) { + prefs.setDisabledCalendarIds(next) + if (!disabled) recoverReminders(current intersect ids.toSet()) + } + } + } + + /** + * Re-post the reminders that fired while [reEnabledIds] were disabled and are + * still relevant (event not yet over), then drop them from the stash. Runs + * after the disabled set is written, so the notifier's own disabled gate lets + * them through. Best-effort at re-enable time: it mirrors the receiver gates + * (reminders on + postable), and there is no later re-scan, so alerts left + * unposted because those gates are closed are simply released. + */ + private suspend fun recoverReminders(reEnabledIds: Set) { + if (reEnabledIds.isEmpty()) return + val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis()) + if (recovered.isNotEmpty() && + settingsPrefs.remindersEnabled.first() && + notifier.canPost() + ) { + recovered.forEach { notifier.post(it) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index 2b66b98..37b2c29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -59,6 +59,7 @@ fun CalendarDrawer( onSelectView: (CalendarView) -> Unit, onJumpToDate: (LocalDate) -> Unit, onSettings: () -> Unit, + viewOrder: List = IMPLEMENTED_VIEWS, ) { var showDatePicker by remember { mutableStateOf(false) } @@ -73,10 +74,10 @@ fun CalendarDrawer( DrawerHeader() DrawerSectionHeader(stringResource(R.string.view_section)) - IMPLEMENTED_VIEWS.forEachIndexed { index, view -> + viewOrder.forEachIndexed { index, view -> GroupedRow( title = stringResource(view.labelRes), - position = positionOf(index, IMPLEMENTED_VIEWS.size), + position = positionOf(index, viewOrder.size), selected = view == currentView, minHeight = 56.dp, leading = { Icon(view.icon, contentDescription = null) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 57151ab..b701d95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List = /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { + if (available.isEmpty()) return this val i = available.indexOf(this) - if (i < 0) return available.firstOrNull() ?: CalendarView.Month + if (i < 0) return available.first() return available[(i + 1) % available.size] } +/** + * The user's customisation of the top-bar quick-switch button (#24): which views + * it cycles through ([enabled]) and in what [order]. [order] always lists every + * implemented view — the settings screen reorders the whole set — while [cycle] + * is the subset the pill actually steps through, in [order]. The navigation + * drawer keeps its own separate order and always lists every view, so a view + * disabled here stays reachable there. + */ +data class QuickSwitchConfig( + val order: List, + val enabled: Set, +) { + /** Views the pill steps through, in [order]. */ + val cycle: List get() = order.filter { it in enabled } + + companion object { + /** All views, in default order, all enabled. */ + val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet()) + + /** + * Fewest views that keep the switch meaningful — a "switch" needs at + * least two targets, so the settings screen blocks disabling below this. + */ + const val MIN_ENABLED = 2 + } +} + /** * The top-level view back stack (bottom → top): the [default] home view always * sits at the bottom. Pressing back pops one level until only the home view diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt new file mode 100644 index 0000000..3ca1381 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex + +/** + * A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of + * the app. Deliberately a stark, un-themed marker (not a product component) so a + * debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the + * call site so it never reaches a release build. Non-interactive: it's a plain + * label with no pointer handler, so taps fall through to whatever is beneath it. + * + * Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box] + * so it overlays the UI. + */ +@Composable +fun BoxScope.DebugRibbon() { + Text( + text = "DEBUG", + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + // Push the band out so its midline crosses the very corner, then + // rotate it to the classic 45° ribbon. + .offset(x = 36.dp, y = 24.dp) + .rotate(45f) + .background(Color(0xFFB23B00)) + .width(140.dp) + .padding(vertical = 2.dp), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index de2a542..ca949c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -165,6 +165,9 @@ fun GroupedRow( dimmed: Boolean = false, container: Color? = null, minHeight: Dp = 72.dp, + // The 2.dp separation between rows in a run. Suppressed by the reorderable + // list, which owns uniform spacing itself so every slot has the same pitch. + gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, @@ -183,9 +186,10 @@ fun GroupedRow( topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, ) } - val gap = when (position) { - Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) - Position.Bottom, Position.Alone -> Modifier + val gap = when { + !gapBelow -> Modifier + position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp) + else -> Modifier } val itemColors = if (selected) { ListItemDefaults.colors( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 66b24df..c60b91c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -23,9 +24,11 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import android.view.WindowManager import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -117,10 +120,19 @@ fun OptionPicker( } /** - * Reminder-default picker, full-screen: the grouped list (with an optional "Use - * default reminder" row and a "None" row), the [presets] as lead-time rows, and - * a "Custom" row that expands an inline number field plus a segmented unit - * selector. Returns the choice as a [CalendarReminderOverride]. + * Reminder-default picker, full-screen and **multi-select**: each [presets] + * lead time (plus any chosen custom value) is a checkbox row that toggles + * independently, so a default can carry several reminders ("1 week before" *and* + * "on the day"). Two exclusive checkmark rows sit above the list: an explicit + * "No reminder" (both pickers, so the empty state stays visible and reachable) + * and, for a per-calendar picker ([allowInherit]), "Use default reminder", which + * defers to the global default. Clearing the last checked time reverts to "Use + * default" on a per-calendar picker (so an accidental toggle-undo can't silently + * wipe the calendar's default) and to explicit [CalendarReminderOverride.None] + * on the global default (where empty legitimately means no reminder). A "Custom" + * row expands an inline number field plus a unit selector to add an arbitrary + * lead time to the set. Changes apply live via [onSelect]; the user leaves via + * back. */ @Composable fun ReminderDefaultPicker( @@ -131,57 +143,91 @@ fun ReminderDefaultPicker( onSelect: (CalendarReminderOverride) -> Unit, onDismiss: () -> Unit, ) { - val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes - val customSelected = selectedMinutes != null && selectedMinutes !in presets - val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) + // Optimistic local state: once the user edits, the chosen override is + // authoritative while the picker is open, so quick successive toggles compose + // on each other instead of racing the round-trip through the settings flow + // (which would drop a toggle made before the previous write echoes back). + var current by remember { mutableStateOf(selected) } + // Whether the user has toggled anything yet. Until then, [current] keeps + // mirroring [selected]: a picker that first composed against the settings + // flow's initialValue (empty — reachable after process-death restore straight + // onto the Notifications page, before the CalendarProvider-gated combine + // resolves) then adopts the real stored default instead of persisting empty + // on the first tap. After the first edit, local state is authoritative. + var userEdited by remember { mutableStateOf(false) } + LaunchedEffect(selected) { + if (!userEdited) current = selected + } + val inherits = current is CalendarReminderOverride.Inherit + val isNone = current is CalendarReminderOverride.None + val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty() + // Custom (non-preset) lead times seen this session, so unchecking one keeps + // its row (unchecked) until the picker closes instead of vanishing mid-tap, + // which would strand a hand-entered value with no way to re-check it. + val seenCustom = remember { mutableSetOf() } + seenCustom += selectedMinutes.filter { it !in presets } + // Presets plus every custom value seen this session, each as its own row. + val rows = presets + seenCustom.sorted() var customExpanded by rememberSaveable { mutableStateOf(false) } - var amountText by rememberSaveable { mutableStateOf(seed.first) } - var unit by rememberSaveable { mutableStateOf(seed.second) } + var amountText by rememberSaveable { mutableStateOf("") } + var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } - val options = buildList { - if (allowInherit) add(CalendarReminderOverride.Inherit) - add(CalendarReminderOverride.None) - presets.forEach { add(CalendarReminderOverride.Minutes(it)) } + fun apply(override: CalendarReminderOverride) { + userEdited = true + current = override + onSelect(override) } - val rowCount = options.size + 1 // + the custom row + fun emit(minutes: List) = apply(reminderOverrideForMinutes(minutes, allowInherit)) + fun toggle(minute: Int) = + emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) FullScreenPicker(title = title, onDismiss = onDismiss) { - options.forEachIndexed { index, option -> - val isSelected = option == selected + // Exclusive choices in their own group above the multi-select list, since + // each is mutually exclusive with picking specific times: "use default" + // (per-calendar only) and an explicit "no reminder" (both pickers, so the + // empty state stays visible and deliberately reachable). + if (allowInherit) { GroupedRow( - title = reminderOverrideLabel(option), - position = positionOf(index, rowCount), - selected = isSelected, - trailing = if (isSelected) { + title = stringResource(R.string.reminder_use_default), + position = Position.Top, + selected = inherits, + trailing = if (inherits) { { SelectedCheck() } } else { null }, - onClick = { - onSelect(option) - onDismiss() - }, + onClick = { apply(CalendarReminderOverride.Inherit) }, + ) + } + GroupedRow( + title = stringResource(R.string.reminder_none), + position = if (allowInherit) Position.Bottom else Position.Alone, + selected = isNone, + trailing = if (isNone) { + { SelectedCheck() } + } else { + null + }, + onClick = { apply(CalendarReminderOverride.None) }, + ) + Spacer(Modifier.height(24.dp)) + val rowCount = rows.size + 1 // + the custom row + rows.forEachIndexed { index, minute -> + val checked = minute in selectedMinutes + GroupedRow( + title = reminderLeadTimeLabel(minute), + position = positionOf(index, rowCount), + selected = checked, + trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, + onClick = { toggle(minute) }, ) } // When expanded, the Custom row connects downward into the editor card // so the two read as one grouped container (the per-calendar pattern). GroupedRow( - title = if (customSelected) { - stringResource( - R.string.reminder_custom_with_value, - reminderLeadTimeLabel(selectedMinutes!!), - ) - } else { - stringResource(R.string.event_edit_reminder_custom) - }, - position = if (customExpanded) Position.Top else positionOf(options.size, rowCount), - selected = customSelected, - trailing = if (customSelected) { - { SelectedCheck() } - } else { - null - }, + title = stringResource(R.string.event_edit_reminder_custom), + position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), onClick = { customExpanded = !customExpanded }, ) AnimatedVisibility( @@ -195,14 +241,36 @@ fun ReminderDefaultPicker( unit = unit, onUnitChange = { unit = it }, onConfirm = { minutes -> - onSelect(CalendarReminderOverride.Minutes(minutes)) - onDismiss() + emit(selectedMinutes + minutes) + amountText = "" + customExpanded = false }, ) } } } +/** + * The override a lead-time set maps to when emitted from [ReminderDefaultPicker]. + * A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last + * time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a + * per-calendar picker ([allowInherit]) so an accidental toggle-undo can't + * silently wipe the calendar's default, and to explicit + * [CalendarReminderOverride.None] on the global default (where empty legitimately + * means no reminder). Pure so it can be unit-tested. + */ +internal fun reminderOverrideForMinutes( + minutes: List, + allowInherit: Boolean, +): CalendarReminderOverride { + val norm = minutes.distinct().sorted() + return when { + norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm) + allowInherit -> CalendarReminderOverride.Inherit + else -> CalendarReminderOverride.None + } +} + /** * The expanded "Custom" lead-time editor: a tonal card connected to the Custom * row above it (matching the grouped-row system, so the two read as one @@ -434,18 +502,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) { is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) } -@Composable -private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { - CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) - CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) -} - -/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */ -private fun decomposeReminder(minutes: Int?): Pair = when { - minutes == null -> "" to ReminderUnit.Minutes - minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks - minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days - minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours - else -> minutes.toString() to ReminderUnit.Minutes -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt new file mode 100644 index 0000000..f6ce3bd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt @@ -0,0 +1,183 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ +val ReorderableRowHeight: Dp = 64.dp +private val RowGap: Dp = 2.dp + +/** + * A vertical list whose rows can be dragged into a new order by their handle. + * + * Built for the short, fixed grouped-card lists in Settings (#24) — no external + * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings + * screens are a single [androidx.compose.foundation.verticalScroll] column, which + * can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a + * uniform gap, so a row's target slot is simply how many whole pitches it has + * been dragged. The held row follows the finger while the others slide out of + * its way (animated); on release the order is committed immediately with a + * single [onReorder] call, then the held row eases into its new slot as a + * purely visual settle — safe to interrupt with another drag, since the + * commit itself never waits on it. + * + * [rowContent] receives the [Position] for the row's place in the order (to reuse + * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) + * and a `dragHandle` [Modifier] to attach to the element that starts a drag. + */ +@Composable +fun ReorderableColumn( + items: List, + keyOf: (T) -> Any, + onReorder: (List) -> Unit, + modifier: Modifier = Modifier, + rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, +) { + val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() } + val scope = rememberCoroutineScope() + + // Local working copy; re-seeded when the incoming list changes (including the + // echo of our own committed order). + var order by remember(items) { mutableStateOf(items) } + var draggedKey by remember { mutableStateOf(null) } + // Live translation of the held row from its slot (px); also drives the + // release settle — re-based onto the row's new slot once the order commits + // (see onDragEnd), then eased down to zero. + var dragOffset by remember { mutableFloatStateOf(0f) } + // The running release/cancel settle, cancelled if a new drag pre-empts it. + // Purely visual — the order commit (see onDragEnd) never depends on it. + var settleJob by remember { mutableStateOf(null) } + + val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } } + // Whole slots dragged → the slot the held row currently hovers over. Derived + // so recomposition only fires when the *target slot* actually changes, not on + // every dragged pixel: dragOffset moves every frame during a drag, but the + // held row's own translation is already applied in the draw phase via + // graphicsLayer below, so only the neighbours' shift (which depends on this) + // needs to recompose, and only when a slot boundary is actually crossed. + // (Read as a plain val, not `by`, below: a delegated property has a custom + // getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in + // the loop, so the derived value is captured into a real local instead.) + val targetIndex = remember(items, pitchPx) { + derivedStateOf { + val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } } + idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) } + } + }.value + + Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) { + order.forEachIndexed { index, item -> + val key = keyOf(item) + val isDragged = key == draggedKey + + // Slide neighbours by one pitch to open the gap the held row will drop + // into. Snap (not animate) once idle, so committing the new order — which + // moves each row's slot — doesn't visibly fight a lingering animation. + val shift = when { + draggedIndex == null || targetIndex == null || isDragged -> 0f + index in (draggedIndex + 1)..targetIndex -> -pitchPx + index in targetIndex until draggedIndex -> pitchPx + else -> 0f + } + val animatedShift by animateFloatAsState( + targetValue = shift, + animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(), + label = "reorderShift", + ) + + val dragHandle = Modifier.pointerInput(key) { + detectDragGestures( + onDragStart = { + settleJob?.cancel() + draggedKey = key + dragOffset = 0f + }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount.y + }, + onDragEnd = { + val from = order.indexOfFirst { keyOf(it) == key } + if (from < 0) return@detectDragGestures + val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) + if (to != from) { + // Commit synchronously and unconditionally, before the settle + // animation below runs — the commit must not depend on that + // coroutine reaching its end, or a new drag starting within the + // ~160ms settle window would cancel it and silently revert an + // already-finished reorder. + order = order.toMutableList().apply { add(to, removeAt(from)) } + onReorder(order) + // The row now lays out at slot `to` instead of `from`; re-base + // the live offset onto that new slot (same visual position, + // expressed relative to the new one) so the settle below eases + // it the rest of the way instead of jumping. + dragOffset -= (to - from) * pitchPx + } + settleJob = scope.launch { + // Purely visual from here: ease the held row onto its slot, then + // release the drag state. Safe to cancel — the order was already + // committed above. + Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value } + draggedKey = null + dragOffset = 0f + } + }, + onDragCancel = { + settleJob = scope.launch { + Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value } + draggedKey = null + dragOffset = 0f + } + }, + ) + } + + Box( + Modifier + .height(ReorderableRowHeight) + .zIndex(if (isDragged) 1f else 0f) + .graphicsLayer { + translationY = if (isDragged) dragOffset else animatedShift + if (isDragged) { + scaleX = 1.02f + scaleY = 1.02f + shadowElevation = 8.dp.toPx() + shape = RoundedCornerShape(20.dp) + clip = false + } + }, + ) { + rowContent(item, positionOf(index, order.size), dragHandle, isDragged) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 97cbdce..ce43538 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition @@ -116,6 +117,8 @@ fun DayScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -175,6 +178,7 @@ fun DayScreen( CalendarDrawer( currentView = selectedView, currentDate = date, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -196,7 +200,7 @@ fun DayScreen( DayTopBar( date = date, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 50a2f7d..7190beb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor( ) private data class ReminderDefaults( - val timed: Int?, - val allDay: Int?, - val timedOverrides: Map, - val allDayOverrides: Map, + val timed: List, + val allDay: List, + val timedOverrides: Map>, + val allDayOverrides: Map>, ) private data class ExternalInputs( @@ -260,7 +260,7 @@ class EventEditViewModel @Inject constructor( // Re-check after suspending: bail if the form closed or the user edited. val form = _form.value ?: return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch - val default = resolveDefaultReminder( + val reminders = resolveDefaultReminder( timedGlobal = defaults.timed, allDayGlobal = defaults.allDay, timedOverrides = defaults.timedOverrides, @@ -268,7 +268,6 @@ class EventEditViewModel @Inject constructor( calendarId = targetId, isAllDay = form.isAllDay, ) - val reminders = listOfNotNull(default) _form.value = form.copy(reminders = reminders) // Surface the section so an auto-applied default is visible and // removable, even when Reminders isn't a default-shown field. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f57f6e3..3413ce7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -98,6 +99,8 @@ fun MonthScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -157,6 +160,7 @@ fun MonthScreen( CalendarDrawer( currentView = selectedView, currentDate = LocalDate(month.year, month.month, 1), + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -178,7 +182,7 @@ fun MonthScreen( MonthTopBar( month = month, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d3fc450..8d5a4d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -7,9 +7,11 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Icon +import android.net.Uri import android.os.Build import android.os.PowerManager import android.provider.Settings +import android.widget.Toast import androidx.annotation.RequiresApi import android.text.format.DateFormat import androidx.activity.compose.rememberLauncherForActivityResult @@ -41,6 +43,8 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite @@ -51,6 +55,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -60,6 +65,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,6 +80,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -95,13 +102,20 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn +import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight +import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.OptionPicker @@ -115,13 +129,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel +import de.jeanlucmakiola.calendula.ui.theme.BundledFont +import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime import java.time.format.TextStyle as JavaTextStyle import java.util.Calendar /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, EventForm, Notifications } +private enum class SettingsSection { Appearance, Views, EventForm, Notifications } /** * Token-based accent for a leading icon chip (container / on-container pair). @@ -166,6 +184,13 @@ fun SettingsScreen( ) { AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + AnimatedVisibility( + visible = section == SettingsSection.Views, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } AnimatedVisibility( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), @@ -204,6 +229,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_views), + summary = stringResource(R.string.settings_views_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Views) }, + ) GroupedRow( title = stringResource(R.string.settings_section_event_form), summary = stringResource(R.string.settings_event_form_subtitle), @@ -477,6 +509,18 @@ private fun AppearanceScreen( var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) } + var showBrandFont by remember { mutableStateOf(false) } + var showPlainFont by remember { mutableStateOf(false) } + + val fonts by viewModel.fontState.collectAsStateWithLifecycle() + // A picked file that didn't parse as a font: tell the user and keep the old choice. + val context = LocalContext.current + val importFailedMessage = stringResource(R.string.settings_font_import_failed) + LaunchedEffect(Unit) { + viewModel.fontImportFailed.collect { + Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show() + } + } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -513,6 +557,24 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) + // Fonts — the two Material typeface roles, each independently choosable + // (issue #19). Headings = brand (display/headline); body = plain + // (title/body/label). Both default to the system typeface. + GroupedRow( + title = stringResource(R.string.settings_font_headings), + summary = fontLabel(fonts.brand), + position = Position.Top, + onClick = { showBrandFont = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_font_body), + summary = fontLabel(fonts.plain), + position = Position.Bottom, + onClick = { showPlainFont = true }, + ) + + Spacer(Modifier.height(16.dp)) + // Calendar — view, week, and timeline formatting GroupedRow( title = stringResource(R.string.settings_default_view), @@ -604,6 +666,28 @@ private fun AppearanceScreen( onDismiss = { showTheme = false }, ) } + if (showBrandFont) { + FontPicker( + title = stringResource(R.string.settings_font_headings), + role = FontRole.BRAND, + selected = fonts.brand, + stamp = fonts.brandStamp, + onSelect = { viewModel.setFont(FontRole.BRAND, it) }, + onImport = { viewModel.importCustomFont(FontRole.BRAND, it) }, + onDismiss = { showBrandFont = false }, + ) + } + if (showPlainFont) { + FontPicker( + title = stringResource(R.string.settings_font_body), + role = FontRole.PLAIN, + selected = fonts.plain, + stamp = fonts.plainStamp, + onSelect = { viewModel.setFont(FontRole.PLAIN, it) }, + onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) }, + onDismiss = { showPlainFont = false }, + ) + } if (showWeekStart) { OptionPicker( title = stringResource(R.string.settings_week_start), @@ -664,6 +748,128 @@ private fun AppearanceScreen( } } +/** + * Views (#24): reorder the top-bar quick-switch cycle and choose which views it + * steps through, plus reorder the navigation-drawer list. Two independent lists — + * a view disabled in the quick-switch cycle is still reachable from the drawer, + * which always lists every view. The switch needs at least two targets, so the + * last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off. + */ +@Composable +private fun ViewsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_views), + onBack = onBack, + ) { + val config = state.quickSwitchConfig + + SectionHeader(stringResource(R.string.settings_quick_switch_header)) + SettingsHint(stringResource(R.string.settings_quick_switch_hint)) + Spacer(Modifier.height(8.dp)) + // Turning a view off is blocked once only the minimum remain enabled. + val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED + ReorderableColumn( + items = config.order, + keyOf = { it }, + onReorder = { viewModel.setQuickSwitchOrder(it) }, + ) { view, position, dragHandle, isDragging -> + val checked = view in config.enabled + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + dimmed = !checked, + trailing = { + Switch( + checked = checked, + // Keep the last two on: with fewer, the pill can't switch. + enabled = !checked || canDisable, + onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) }, + ) + }, + ) + } + + Spacer(Modifier.height(24.dp)) + SectionHeader(stringResource(R.string.settings_drawer_order_header)) + SettingsHint(stringResource(R.string.settings_drawer_order_hint)) + Spacer(Modifier.height(8.dp)) + ReorderableColumn( + items = state.drawerViewOrder, + keyOf = { it }, + onReorder = { viewModel.setDrawerViewOrder(it) }, + ) { view, position, dragHandle, isDragging -> + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + ) + } + } +} + +/** One reorderable view row: the view's icon and name, an optional [trailing] + * control, and a drag handle carrying the [dragHandle] gesture modifier. */ +@Composable +private fun ViewRow( + view: CalendarView, + position: Position, + isDragging: Boolean, + dragHandle: Modifier, + dimmed: Boolean = false, + trailing: @Composable (() -> Unit)? = null, +) { + GroupedRow( + title = stringResource(view.labelRes), + position = position, + dimmed = dimmed, + minHeight = ReorderableRowHeight, + // The reorderable column owns the inter-row spacing (uniform pitch). + gapBelow = false, + container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, + leading = { + Icon( + imageVector = view.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailing?.invoke() + if (trailing != null) Spacer(Modifier.width(8.dp)) + Box( + modifier = dragHandle.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.reorder_drag_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) +} + +/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */ +@Composable +private fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) +} + @Composable private fun EventFormScreen( state: SettingsUiState, @@ -969,7 +1175,7 @@ private fun NotificationsScreen( presets = REMINDER_PRESETS, selected = state.defaultReminderMinutes.toReminderChoice(), allowInherit = false, - onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) }, + onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) }, onDismiss = { showDefaultReminder = false }, ) } @@ -979,7 +1185,7 @@ private fun NotificationsScreen( presets = ALLDAY_REMINDER_PRESETS, selected = state.defaultAllDayReminderMinutes.toReminderChoice(), allowInherit = false, - onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) }, + onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) }, onDismiss = { showAllDayReminder = false }, ) } @@ -1028,13 +1234,13 @@ private fun NotificationsScreen( /** Which calendar + event kind a per-calendar reminder-override dialog targets. */ private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) -/** A global default (null = none) as a picker choice for selection highlighting. */ -private fun Int?.toReminderChoice(): CalendarReminderOverride = - if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) +/** A global default (empty = none) as a picker choice for selection highlighting. */ +private fun List.toReminderChoice(): CalendarReminderOverride = + if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) /** A picked choice as global-default minutes (Inherit isn't offered for globals). */ -private fun CalendarReminderOverride.toMinutesOrNull(): Int? = - (this as? CalendarReminderOverride.Minutes)?.minutes +private fun CalendarReminderOverride.toMinutesList(): List = + (this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList() /** * Whether Calendula is exempt from battery optimisation, re-read on every @@ -1109,27 +1315,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { } /** The stored override for [calendarId], as a picker choice (absent → inherit). */ -private fun Map.choiceFor(calendarId: Long): CalendarReminderOverride = when { +private fun Map>.choiceFor(calendarId: Long): CalendarReminderOverride = when { !containsKey(calendarId) -> CalendarReminderOverride.Inherit - this[calendarId] == null -> CalendarReminderOverride.None - else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) + this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None + else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)) } -/** Label for a global-default choice: null → "None", else the lead time. */ +/** Label for a global-default choice: empty → "None", else the lead times joined. */ @Composable -private fun reminderChoiceLabel(minutes: Int?): String = - if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes) +private fun reminderChoiceLabel(minutes: List): String { + if (minutes.isEmpty()) return stringResource(R.string.reminder_none) + // reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather + // than a (non-composable) joinToString transform. + val parts = ArrayList(minutes.size) + for (m in minutes) parts.add(reminderLeadTimeLabel(m)) + return parts.joinToString(", ") +} /** Row summary for a calendar: its override, or the inherited global default. */ @Composable private fun calendarOverrideSummary( choice: CalendarReminderOverride, - globalDefault: Int?, + globalDefault: List, ): String = when (choice) { CalendarReminderOverride.Inherit -> stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) + is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes) } // --------------------------------------------------------------------------- @@ -1180,6 +1392,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource( }, ) +/** The summary label for a stored font token (issue #19). */ +@Composable +private fun fontLabel(token: String): String = when (token) { + FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system) + FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected) + else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) } + ?: stringResource(R.string.settings_font_system) +} + +/** + * MIME types offered to the document picker so it lists only font files. Covers + * the modern `font/` types plus the legacy `application/` font aliases some + * providers still report. Anything that slips through is still validated by + * [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use. + */ +private val FONT_PICKER_MIME_TYPES = arrayOf( + "font/ttf", + "font/otf", + "font/sfnt", + "font/collection", + "application/x-font-ttf", + "application/x-font-otf", + "application/font-sfnt", + "application/vnd.ms-opentype", +) + +/** + * Full-screen font chooser for one [FontRole]: the system default, each bundled + * font (previewed in its own face), and "Choose file…" which opens the system + * picker to load a .ttf/.otf. Selecting a system/bundled option applies at once; + * a file is validated and imported by the caller, switching to the custom font + * on success. + */ +@Composable +private fun FontPicker( + title: String, + role: FontRole, + selected: String, + stamp: Int, + onSelect: (String) -> Unit, + onImport: (Uri) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + onImport(uri) + onDismiss() + } + } + + // System default + the bundled fonts + the "Choose file…" row. + val rowCount = BundledFont.entries.size + 2 + val isCustom = selected == FONT_CUSTOM_TOKEN + // Resolving the custom face stats the disk and builds a fresh FontFamily, so + // memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file + // refreshes the preview while plain recompositions reuse the cached family. + val customPreview = remember(role, isCustom, stamp) { + if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null + } + + FullScreenPicker(title = title, onDismiss = onDismiss) { + FontOptionRow( + label = stringResource(R.string.settings_font_system), + preview = FontFamily.Default, + selected = selected == FONT_SYSTEM_TOKEN, + position = positionOf(0, rowCount), + onClick = { + onSelect(FONT_SYSTEM_TOKEN) + onDismiss() + }, + ) + BundledFont.entries.forEachIndexed { index, font -> + FontOptionRow( + label = stringResource(font.labelRes), + preview = font.family, + selected = selected == font.token, + position = positionOf(index + 1, rowCount), + onClick = { + onSelect(font.token) + onDismiss() + }, + ) + } + FontOptionRow( + label = if (isCustom) { + stringResource(R.string.settings_font_custom_selected) + } else { + stringResource(R.string.settings_font_choose_file) + }, + // A loaded font previews in its own face; otherwise show an upload cue. + preview = customPreview, + leadingIcon = if (isCustom) null else Icons.Default.UploadFile, + selected = isCustom, + position = positionOf(rowCount - 1, rowCount), + onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) }, + ) + } +} + +/** + * One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in + * the option's own [preview] face (or an [leadingIcon] cue when there's nothing + * to preview), and a check when it's the current selection. + */ +@Composable +private fun FontOptionRow( + label: String, + preview: FontFamily?, + selected: Boolean, + position: Position, + onClick: () -> Unit, + leadingIcon: ImageVector? = null, +) { + GroupedRow( + title = label, + position = position, + selected = selected, + leading = { + if (preview != null) { + Text( + text = "Ag", + fontFamily = preview, + style = MaterialTheme.typography.titleLarge, + ) + } else if (leadingIcon != null) { + Icon(imageVector = leadingIcon, contentDescription = null) + } + }, + trailing = if (selected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + onClick = onClick, + ) +} + /** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ private val WEEK_START_OPTIONS: List = listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 52c23ae..8f8a710 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -9,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField 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 /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -37,6 +39,10 @@ data class SettingsUiState( val agendaShowRangeBar: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, + /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ + val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, + /** Order of the views in the navigation drawer (#24); every view is always listed. */ + val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, /** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */ @@ -44,24 +50,24 @@ data class SettingsUiState( /** Whether Calendula posts reminder notifications (v1.4). */ val remindersEnabled: Boolean = true, /** - * The default reminder lead time (minutes) prefilled on new timed events; - * null = no default reminder. Per-calendar overrides take precedence. + * The default reminder lead times (minutes) prefilled on new timed events; + * empty = no default reminder. Per-calendar overrides take precedence. */ - val defaultReminderMinutes: Int? = null, - /** The default reminder lead time prefilled on new all-day events; null = none. */ - val defaultAllDayReminderMinutes: Int? = null, + val defaultReminderMinutes: List = emptyList(), + /** The default reminder lead times prefilled on new all-day events; empty = none. */ + val defaultAllDayReminderMinutes: List = emptyList(), /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, /** How long the notification "Snooze" action defers a reminder; default 10 min. */ val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, /** * Per-calendar overrides of [defaultReminderMinutes] for timed events: a - * calendar present in the map overrides the global default (null value = no + * calendar present in the map overrides the global default (empty value = no * reminder); absent = inherit the global default. */ - val perCalendarReminderOverride: Map = emptyMap(), + val perCalendarReminderOverride: Map> = emptyMap(), /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ - val perCalendarAllDayReminderOverride: Map = emptyMap(), + val perCalendarAllDayReminderOverride: Map> = emptyMap(), /** Writable calendars, shown as per-calendar reminder-override rows. */ val writableCalendars: List = emptyList(), /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 557ab21..a3c7fc3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context +import android.net.Uri import android.os.Build import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState @@ -10,6 +11,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs @@ -18,14 +21,21 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings +import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -35,12 +45,14 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, @ApplicationContext private val appContext: Context, ) : ViewModel() { @@ -103,8 +115,14 @@ class SettingsViewModel @Inject constructor( prefs.autofocusEventTitle, prefs.pastEventDisplay, prefs.dimCompletedEvents, - ::MiscSettings, - ), + // View customisation (#24) folded into one flow so it fits this + // group — the outer combine is already at its five-arg limit. + combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer -> + ViewCustomization(quickSwitch, drawer) + }, + ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> + MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) + }, ) { base, defaults, overrides, views, misc -> base.copy( defaultView = views.defaultView, @@ -116,6 +134,8 @@ class SettingsViewModel @Inject constructor( autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, dimCompletedEvents = misc.dimCompletedEvents, + quickSwitchConfig = misc.viewCustomization.quickSwitch, + drawerViewOrder = misc.viewCustomization.drawerOrder, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -131,6 +151,29 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) + /** + * The app-wide custom-font choice (issue #19). Its own flow — both this + * screen and [MainActivity] (which builds the typography) read it + * independently of the main settings combine. + */ + val fontState: StateFlow = combine( + prefs.brandFont, + prefs.plainFont, + prefs.brandFontStamp, + prefs.plainFontStamp, + ) { brand, plain, brandStamp, plainStamp -> + AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = AppFontSettings(), + ) + + // Emitted when a picked font file couldn't be read as a font; the screen + // surfaces it and the previous selection stays put. + private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) + val fontImportFailed: SharedFlow = _fontImportFailed + // Serialises widget redraws so a rapid flip-and-flip-back can't run two // updateAll calls at once — concurrent calls coalesce around a stale read // and can leave the widget on the intermediate value. Held sequentially, @@ -139,15 +182,15 @@ class SettingsViewModel @Inject constructor( private data class ReminderDefaults( val allowColor: Boolean, - val defaultReminder: Int?, - val allDayReminder: Int?, + val defaultReminder: List, + val allDayReminder: List, val allDayReminderTime: Int, val snoozeMinutes: Int, ) private data class ReminderOverrides( - val timed: Map, - val allDay: Map, + val timed: Map>, + val allDay: Map>, val calendars: List, ) @@ -164,6 +207,12 @@ class SettingsViewModel @Inject constructor( val autofocusEventTitle: Boolean, val pastEventDisplay: PastEventDisplay, val dimCompletedEvents: Boolean, + val viewCustomization: ViewCustomization, + ) + + private data class ViewCustomization( + val quickSwitch: QuickSwitchConfig, + val drawerOrder: List, ) fun setThemeMode(mode: ThemeMode) { @@ -174,6 +223,35 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setDynamicColor(enabled) } } + fun setFont(role: FontRole, token: String) { + viewModelScope.launch { + when (role) { + FontRole.BRAND -> prefs.setBrandFont(token) + FontRole.PLAIN -> prefs.setPlainFont(token) + } + } + } + + /** + * Import a user-picked font file for [role]. Copies and validates it off the + * main thread; on success the role switches to the custom font, otherwise the + * previous choice is kept and [fontImportFailed] fires. + */ + fun importCustomFont(role: FontRole, uri: Uri) { + viewModelScope.launch { + val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) } + if (ok) { + // Bump the stamp so replacing an already-active custom font (same + // file, same "custom" token) still breaks font-settings equality + // and refreshes the resolved typeface. + prefs.bumpCustomFontStamp(role) + setFont(role, FONT_CUSTOM_TOKEN) + } else { + _fontImportFailed.emit(Unit) + } + } + } + fun setWeekStart(pref: WeekStartPref) { viewModelScope.launch { prefs.setWeekStart(pref) @@ -247,6 +325,32 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } } + /** + * Enable or disable [view] in the quick-switch cycle. Goes through an atomic + * read-modify-write so a concurrent reorder can't clobber this toggle (and + * vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is + * re-checked inside the transform: the screen's own guard reads the + * async-echoed snapshot, so two quick disable-taps could both look allowed + * yet compose to a below-minimum cycle. + */ + fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) { + viewModelScope.launch { + prefs.updateQuickSwitch { config -> + val next = if (enabled) config.enabled + view else config.enabled - view + if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next) + } + } + } + + /** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */ + fun setQuickSwitchOrder(order: List) { + viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } } + } + + fun setDrawerViewOrder(order: List) { + viewModelScope.launch { prefs.setDrawerViewOrder(order) } + } + fun setRemindersEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(enabled) } } @@ -255,11 +359,11 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) } } - fun setDefaultReminderMinutes(minutes: Int?) { + fun setDefaultReminderMinutes(minutes: List) { viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } } - fun setDefaultAllDayReminderMinutes(minutes: Int?) { + fun setDefaultAllDayReminderMinutes(minutes: List) { viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt new file mode 100644 index 0000000..1dacb99 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore +import de.jeanlucmakiola.calendula.domain.FontRole +import java.io.File + +/** Stored token for "use the device default typeface" — the default for both roles. */ +const val FONT_SYSTEM_TOKEN = "system" + +/** Stored token for "use the font the user loaded from a file" (per role). */ +const val FONT_CUSTOM_TOKEN = "custom" + +/** + * A font checked into the app that a user can pick without providing a file + * (issue #19). [token] is the stable value persisted in preferences; [family] is + * the resolved Compose family. Kept deliberately small and readability-focused. + */ +enum class BundledFont( + val token: String, + @StringRes val labelRes: Int, + val family: FontFamily, +) { + /** Purpose-built for low-vision legibility (Braille Institute). */ + AtkinsonHyperlegible( + token = "atkinson_hyperlegible", + labelRes = R.string.font_atkinson_hyperlegible, + family = FontFamily( + Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal), + Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold), + ), + ), + + /** A serif alternative; shipped as a variable font, weights via its wght axis. */ + Lora( + token = "lora", + labelRes = R.string.font_lora, + family = FontFamily( + Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)), + Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)), + Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)), + ), + ), + + /** A monospace option. */ + JetBrainsMono( + token = "jetbrains_mono", + labelRes = R.string.font_jetbrains_mono, + family = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold), + ), + ); + + companion object { + fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token } + } +} + +private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight)) + +/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */ +data class AppFontSettings( + val brand: String = FONT_SYSTEM_TOKEN, + val plain: String = FONT_SYSTEM_TOKEN, + // Per-role custom-font re-import stamp: bumped when the "custom" file is + // replaced, so a same-token re-import still breaks equality and refreshes the + // resolved family. 0 for existing installs / no import yet (issue #19). + val brandStamp: Int = 0, + val plainStamp: Int = 0, +) + +/** + * Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean + * "leave the Material default" (the system typeface). A custom token whose file + * is missing or unreadable also resolves to null, so a deleted file degrades to + * the system font rather than crashing text layout. + */ +fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) { + FONT_SYSTEM_TOKEN -> null + FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role)) + else -> BundledFont.fromToken(token)?.family +} + +private fun customFontFamily(file: File): FontFamily? = + if (file.exists() && file.length() > 0) { + runCatching { FontFamily(Font(file)) }.getOrNull() + } else { + null + } + +/** + * Build the app typography from the chosen [brand] and [plain] families. A null + * family leaves that role's Material default (system typeface) in place, so the + * common "system for both" case returns [CalendulaTypography] untouched. Brand + * drives display + headline; plain drives title + body + label (M3's two + * typeface roles). + */ +fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography { + if (brand == null && plain == null) return CalendulaTypography + val base = CalendulaTypography + fun TextStyle.withFamily(family: FontFamily?): TextStyle = + if (family != null) copy(fontFamily = family) else this + return base.copy( + displayLarge = base.displayLarge.withFamily(brand), + displayMedium = base.displayMedium.withFamily(brand), + displaySmall = base.displaySmall.withFamily(brand), + headlineLarge = base.headlineLarge.withFamily(brand), + headlineMedium = base.headlineMedium.withFamily(brand), + headlineSmall = base.headlineSmall.withFamily(brand), + titleLarge = base.titleLarge.withFamily(plain), + titleMedium = base.titleMedium.withFamily(plain), + titleSmall = base.titleSmall.withFamily(plain), + bodyLarge = base.bodyLarge.withFamily(plain), + bodyMedium = base.bodyMedium.withFamily(plain), + bodySmall = base.bodySmall.withFamily(plain), + labelLarge = base.labelLarge.withFamily(plain), + labelMedium = base.labelMedium.withFamily(plain), + labelSmall = base.labelSmall.withFamily(plain), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt index 8b42f63..652aa77 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme +import androidx.compose.material3.Typography import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable @@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext * - System light/dark. * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme * derived from [CalendulaSeed]. + * - A user-chosen [typography] (custom fonts, issue #19), defaulting to the + * Material scale on the system typeface. * - * The Settings screen (later) can override useDynamicColor and themePreference, - * but the V1 foundation just follows the system. + * The Settings screen overrides darkTheme, dynamicColor, and typography; the + * bare defaults (used by the crash screen) just follow the system. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CalendulaTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + typography: Typography = CalendulaTypography, content: @Composable () -> Unit, ) { val colorScheme = when { @@ -42,7 +46,7 @@ fun CalendulaTheme( // the bouncy variant felt overdone in review (2026-06-11). MaterialExpressiveTheme( colorScheme = colorScheme, - typography = CalendulaTypography, + typography = typography, motionScheme = MotionScheme.standard(), content = content, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index e91de43..c40ab29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -131,6 +132,8 @@ fun WeekScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -195,6 +198,7 @@ fun WeekScreen( CalendarDrawer( currentView = selectedView, currentDate = weekStart, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -216,7 +220,7 @@ fun WeekScreen( WeekTopBar( weekStart = weekStart, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 19c06c5..0174eed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -193,6 +193,8 @@ private fun AgendaHeader() { modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Tap the "Upcoming" title to open the app on the user's default view + // (issue #20) — a null target resolves to the default home in the host. Text( text = context.getString(R.string.widget_agenda_title), style = TextStyle( @@ -200,7 +202,11 @@ private fun AgendaHeader() { fontSize = 16.sp, fontWeight = FontWeight.Medium, ), - modifier = GlanceModifier.defaultWeight(), + modifier = GlanceModifier + .defaultWeight() + .clickable( + actionStartActivity(MainActivity.openViewIntent(context, view = null)), + ), ) IconButton( resId = R.drawable.ic_widget_refresh, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 1799bd5..89b401b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -194,6 +194,8 @@ private fun MonthHeader(label: String) { ), ), ) + // Tap the month/year title to open the app on the month view (issue #18), + // rooted over the default home so backing out returns there. Text( text = label, style = TextStyle( @@ -204,16 +206,17 @@ private fun MonthHeader(label: String) { ), modifier = GlanceModifier .defaultWeight() - .clickable(actionRunCallback()), + .clickable( + actionStartActivity( + MainActivity.openViewIntent(context, CalendarView.Month), + ), + ), ) + // The "today" button snaps the grid back to the current month in place. HeaderIcon( resId = R.drawable.ic_widget_today, contentDescription = context.getString(R.string.widget_today), - onClick = GlanceModifier.clickable( - actionStartActivity( - MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month), - ), - ), + onClick = GlanceModifier.clickable(actionRunCallback()), ) HeaderIcon( resId = R.drawable.ic_widget_chevron_right, @@ -301,6 +304,15 @@ private fun WeekRow( } } +/** + * Open [date]'s day view rooted in the month view (so back returns to the month + * grid) — the same target the in-app month grid uses when a day cell is tapped. + * Shared by every tappable part of a day column so, as in the app, a tap anywhere + * on a day opens it; only an event bar on top opts out to open its own detail. + */ +private fun openDayAction(context: Context, date: LocalDate) = + actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) + @Composable private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { val context = LocalContext.current @@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: modifier = GlanceModifier .width(colW) .height(DAY_NUMBER_HEIGHT) - // Tap a day number to open that day, rooted in the month view so back - // returns to the month grid. - .clickable( - actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)), - ), + .clickable(openDayAction(context, date)), contentAlignment = Alignment.Center, ) { Box( @@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: @Composable private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { + val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { var col = 0 while (col < 7) { @@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { if (timed != null) { SpanBar(event = timed, dark = dark, width = colW) } else { - Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {} + // Empty lane cell: a tap opens that day, so blank space in a + // day column is a day-open target just like the number is. + Box( + GlanceModifier + .width(colW) + .height(LANE_HEIGHT) + .clickable(openDayAction(context, week.days[col])), + ) {} } col += 1 } @@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { @Composable private fun OverflowRow(week: MonthWeek, colW: Dp) { + val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { week.days.forEachIndexed { col, date -> val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown + // The overflow row is part of the day column too: tapping it (whether + // it shows "+N" or is blank) opens that day, same as the app. Box( - modifier = GlanceModifier.width(colW).height(LANE_HEIGHT), + modifier = GlanceModifier + .width(colW) + .height(LANE_HEIGHT) + .clickable(openDayAction(context, date)), contentAlignment = Alignment.CenterStart, ) { if (hidden > 0) { diff --git a/app/src/main/res/font/atkinson_hyperlegible_bold.ttf b/app/src/main/res/font/atkinson_hyperlegible_bold.ttf new file mode 100644 index 0000000..ca3f613 Binary files /dev/null and b/app/src/main/res/font/atkinson_hyperlegible_bold.ttf differ diff --git a/app/src/main/res/font/atkinson_hyperlegible_regular.ttf b/app/src/main/res/font/atkinson_hyperlegible_regular.ttf new file mode 100644 index 0000000..b3cc420 Binary files /dev/null and b/app/src/main/res/font/atkinson_hyperlegible_regular.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_bold.ttf b/app/src/main/res/font/jetbrains_mono_bold.ttf new file mode 100644 index 0000000..cd1bee0 Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_bold.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_regular.ttf b/app/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/app/src/main/res/font/lora.ttf b/app/src/main/res/font/lora.ttf new file mode 100644 index 0000000..ee1914c Binary files /dev/null and b/app/src/main/res/font/lora.ttf differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9b83e46..aa65ccd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -295,7 +295,6 @@ Keine Standard-Erinnerung verwenden Anzahl - Benutzerdefiniert (%1$s) Übernehmen Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen. Standard (%1$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d121f90..2995a32 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,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 +317,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 +339,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,6 +357,7 @@ 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 About 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/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index eec1075..a8cd3fe 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,11 @@ 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.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 +153,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 +275,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 +314,7 @@ class SettingsPrefsTest { m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m } - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test @@ -214,65 +322,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 +439,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)) 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/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/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.