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 4565119..962e46f 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,6 +30,7 @@ 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 @@ -115,19 +117,24 @@ class MainActivity : AppCompatActivity() { 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( @@ -200,6 +207,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 { @@ -251,6 +264,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" @@ -314,5 +331,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/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 9937ca7..d73d49f 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 @@ -12,6 +12,8 @@ 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 @@ -224,6 +226,34 @@ 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) } + } + + /** + * 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 @@ -298,35 +328,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() } } /** @@ -363,13 +394,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]) } @@ -386,7 +417,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]) } @@ -455,6 +486,40 @@ 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") @@ -469,6 +534,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") @@ -519,43 +586,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()) } } @@ -581,24 +651,36 @@ 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 + id to parts[1].toReminderList() }.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..dc6871f 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,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +/** + * The due alerts that should actually surface as notifications: everything + * except alerts whose calendar the user has disabled in-app (mirroring the + * event filtering in CalendarRepositoryImpl). The caller still marks the full + * due set fired, so suppressed alerts are not re-broadcast by the provider. + */ +internal fun postableAlerts( + due: List, + disabledCalendarIds: Set, +): List = due.filterNot { it.calendarId in disabledCalendarIds } + /** * Becomes the app that turns the calendar provider's reminder alarms into * visible notifications (the Etar model — the provider broadcasts @@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() { @Inject lateinit var alertStore: ReminderAlertStore @Inject lateinit var notifier: ReminderNotifier @Inject lateinit var settingsPrefs: SettingsPrefs + @Inject lateinit var calendarPrefs: CalendarPrefs override fun onReceive(context: Context, intent: Intent) { if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return @@ -46,7 +59,11 @@ 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() + // Suppress reminders for disabled calendars, but still mark + // every due alert fired so the provider stops re-broadcasting + // the suppressed ones. + postableAlerts(due, disabled).forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) } } finally { 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/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/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 c183aab..dd39503 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 @@ -142,6 +142,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, @@ -160,9 +163,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 ce6abae..847b5a1 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 @@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable 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 @@ -111,10 +113,15 @@ 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"). A per-calendar picker ([allowInherit]) gains an exclusive "Use + * default reminder" row above the list; choosing it defers to the global default + * and clears the checked times. With nothing checked and inherit off, the choice + * is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row + * expands an inline number field plus a unit selector to add an arbitrary lead + * time to the set. Changes apply live via [onSelect]; the user leaves via back. */ @Composable fun ReminderDefaultPicker( @@ -125,57 +132,74 @@ 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: the chosen override is authoritative while the + // picker is open, so quick successive toggles compose on each other instead + // of racing the round-trip through the settings flow (which would drop a + // toggle made before the previous write echoes back). Seeded once on open; + // the picker leaves composition on dismiss, so reopening re-reads [selected]. + var current by remember { mutableStateOf(selected) } + val inherits = current is CalendarReminderOverride.Inherit + val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty() + // Presets plus any selected custom (non-preset) values, each as its own row. + val rows = presets + selectedMinutes.filter { it !in presets }.sorted() 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) { + current = override + onSelect(override) } - val rowCount = options.size + 1 // + the custom row + // Emit the override for a new set of minutes: empty collapses to None, which + // (with inherit unchosen) reads as an explicit "no reminder". + fun emit(minutes: List) { + val norm = minutes.distinct().sorted() + apply( + if (norm.isEmpty()) { + CalendarReminderOverride.None + } else { + CalendarReminderOverride.Minutes(norm) + }, + ) + } + fun toggle(minute: Int) = + emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) FullScreenPicker(title = title, onDismiss = onDismiss) { - options.forEachIndexed { index, option -> - val isSelected = option == selected + // Exclusive "use default" choice (per-calendar only): its own group above + // the multi-select list, since inheriting is mutually exclusive with + // picking specific times. + if (allowInherit) { GroupedRow( - title = reminderOverrideLabel(option), - position = positionOf(index, rowCount), - selected = isSelected, - trailing = if (isSelected) { + title = stringResource(R.string.reminder_use_default), + position = Position.Alone, + selected = inherits, + trailing = if (inherits) { { SelectedCheck() } } else { null }, - onClick = { - onSelect(option) - onDismiss() - }, + onClick = { apply(CalendarReminderOverride.Inherit) }, + ) + 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( @@ -189,8 +213,9 @@ fun ReminderDefaultPicker( unit = unit, onUnitChange = { unit = it }, onConfirm = { minutes -> - onSelect(CalendarReminderOverride.Minutes(minutes)) - onDismiss() + emit(selectedMinutes + minutes) + amountText = "" + customExpanded = false }, ) } @@ -428,18 +453,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..d6a1f5e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt @@ -0,0 +1,158 @@ +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.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 held row settles into its slot, then the + * order is committed with a single [onReorder] call. + * + * [rowContent] receives the [Position] for the row's place in the order (to reuse + * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) + * 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, animated back onto a slot before the order is committed. + var dragOffset by remember { mutableFloatStateOf(0f) } + // The running release/cancel settle, cancelled if a new drag pre-empts it. + 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. + val targetIndex = draggedIndex?.let { + (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) + } + + 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) + settleJob = scope.launch { + // Settle the held row onto its target slot, then commit — + // resetting offset and slot in the same frame, so nothing jumps. + Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) { + dragOffset = value + } + if (to != from) { + order = order.toMutableList().apply { add(to, removeAt(from)) } + onReorder(order) + } + 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 95870ca..9ee4c72 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 @@ -43,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 @@ -106,9 +108,14 @@ 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 @@ -132,7 +139,7 @@ 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). @@ -177,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(), @@ -215,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), @@ -725,6 +746,131 @@ 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.setQuickSwitchConfig(config.copy(order = 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 -> + val enabled = if (on) config.enabled + view else config.enabled - view + viewModel.setQuickSwitchConfig(config.copy(enabled = enabled)) + }, + ) + }, + ) + } + + 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, @@ -1030,7 +1176,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 }, ) } @@ -1040,7 +1186,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 }, ) } @@ -1089,13 +1235,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 @@ -1170,27 +1316,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) } // --------------------------------------------------------------------------- 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 e2889e8..c510e19 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 @@ -25,6 +25,7 @@ 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 @@ -114,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, @@ -127,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, @@ -171,15 +180,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, ) @@ -196,6 +205,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) { @@ -304,6 +319,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } } + fun setQuickSwitchConfig(config: QuickSwitchConfig) { + viewModelScope.launch { prefs.setQuickSwitchConfig(config) } + } + + fun setDrawerViewOrder(order: List) { + viewModelScope.launch { prefs.setDrawerViewOrder(order) } + } + fun setRemindersEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(enabled) } } @@ -312,11 +335,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/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/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 100a87b..6d5443b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -304,7 +304,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 4db478a..2995a32 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,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 @@ -333,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. @@ -352,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..6de61e9 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 @@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat 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 import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -149,6 +152,78 @@ 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 `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 +258,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 +297,7 @@ class SettingsPrefsTest { m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m } - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test @@ -214,16 +305,16 @@ 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() } } @@ -231,48 +322,48 @@ class SettingsPrefsTest { @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 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..222281e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt @@ -0,0 +1,55 @@ +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) + } +} 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() + } }