From b537e143f82bcd677eaacf73ceb5e945ff468559 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 08:07:00 +0200 Subject: [PATCH 1/5] feat(nav): default view setting + widget-aware back stack Replace CalendarHost's single global view slot with a top-level view back stack rooted at a user-configurable default view. A lateral move (pill/drawer/widget) replaces the non-home top; a date tap drills the day view on top; a base-level BackHandler pops one level until only the home view remains, then the system exits. Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch roots the stack in that widget's view: backing out of a day/event opened from the agenda widget returns to Agenda, and from the month widget to Month, instead of always landing on Week. Reminder taps keep the separate detail-key channel and leave the base view untouched. Add a Default view setting (Settings -> Appearance) backing the stack's home view; defaults to Week so existing users see no change. Resolves the two Codeberg reports from @devinside: - #1 [FR] Option to set default view - #2 [Bug] Widget UX improvement Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++ .../jeanlucmakiola/calendula/MainActivity.kt | 68 ++++++++++++++--- .../calendula/data/prefs/SettingsPrefs.kt | 16 ++++ .../calendula/ui/CalendarHost.kt | 73 +++++++++++++++--- .../calendula/ui/CalendarHostViewModel.kt | 29 +++++++ .../calendula/ui/WidgetNavRequest.kt | 22 +++++- .../calendula/ui/common/CalendarView.kt | 20 +++++ .../calendula/ui/settings/SettingsScreen.kt | 19 +++++ .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 9 ++- .../calendula/widget/agenda/AgendaWidget.kt | 10 ++- .../calendula/widget/month/MonthWidget.kt | 5 +- app/src/main/res/values/strings.xml | 3 +- .../calendula/ui/common/ViewBackStackTest.kt | 75 +++++++++++++++++++ 14 files changed, 341 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8882c81..8c94340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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 +- Choose the view Calendula opens on. A new **Default view** setting (Settings → + Appearance) lets you pick Month, Week, Day or Agenda as the view shown each + time you start the app, instead of always opening on Week. Thanks to + @devinside for the suggestion ([#1]). + +### Fixed +- Home-screen widgets now back out the way you came in. Opening a day or event + from the Agenda widget keeps you in the agenda context, and the Month widget + keeps you in the month context — pressing Back returns to that view instead of + dropping you on the week view. The app now keeps a proper view history: Back + steps from a drilled-in day to the view you opened it from, then to your + default view, then leaves the app. Thanks to @devinside for reporting ([#2]). + ## [2.8.0] — 2026-06-23 ### Added @@ -572,3 +588,6 @@ automatically, with zero telemetry and no internet permission. - Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner - F-Droid metadata stubs (DE + EN short/full descriptions) - `.planning/` project-tracking documents + +[#1]: https://codeberg.org/jlmakiola/calendula/issues/1 +[#2]: https://codeberg.org/jlmakiola/calendula/issues/2 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index f0a857a..cbacda4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.WidgetNavRequest +import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport @@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() { return uri.takeIf { it.scheme == "content" || it.scheme == "file" } } - private fun Intent.navRequestOrNull(): WidgetNavRequest? = when { - // Launcher long-press "New event" shortcut. Static shortcut intents - // can't carry typed extras, so the action alone signals create-on-today. - action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null) - getBooleanExtra(EXTRA_CREATE, false) -> - WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO)) - getStringExtra(EXTRA_DATE_ISO) != null -> - WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!) - else -> null + private fun Intent.navRequestOrNull(): WidgetNavRequest? { + val source = sourceViewOrNull() + val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) + return when { + // Launcher long-press "New event" shortcut. Static shortcut intents + // can't carry typed extras, so the action alone signals create-on-today. + action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null) + getBooleanExtra(EXTRA_CREATE, false) -> + WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO)) + // A widget event tap carries both the occurrence and its source view; + // reminders (no source) fall through to [detailKeyOrNull] instead. + source != null && eventId != -1L -> WidgetNavRequest.OpenEvent( + eventId = eventId, + beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L), + endMillis = getLongExtra(EXTRA_END_MILLIS, 0L), + source = source, + ) + source != null && getStringExtra(EXTRA_DATE_ISO) != null -> + WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source) + else -> null + } } + /** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */ + private fun Intent.sourceViewOrNull(): CalendarView? = + getStringExtra(EXTRA_SOURCE_VIEW) + ?.let { name -> CalendarView.entries.firstOrNull { it.name == name } } + private fun Intent.detailKeyOrNull(): LongArray? { + // A widget event tap (source present) is routed through [navRequestOrNull] + // so it can also set the base view; only sourceless reminder taps land here. + if (sourceViewOrNull() != null) return null val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) if (eventId == -1L) return null return longArrayOf( @@ -168,6 +189,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" + // 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" + // Fired by the launcher long-press "New event" shortcut (res/xml/ // shortcuts.xml hardcodes this string — keep the two in sync). const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT" @@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - /** Open the day view anchored on [date] (home-screen widgets). */ - fun openDateIntent(context: Context, date: LocalDate): Intent = + /** + * Open the day view anchored on [date], drilled in over the launching + * widget's [source] view (home-screen widgets). Backing out of the day + * returns to [source], then to the default home view. + */ + fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent = Intent(context, MainActivity::class.java).apply { data = "calendula://date/$date".toUri() putExtra(EXTRA_DATE_ISO, date.toString()) + putExtra(EXTRA_SOURCE_VIEW, source.name) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + /** + * Open one occurrence's detail from a widget event tap, rooting the back + * stack in the widget's [source] view. Same occurrence-key shape as + * [eventDetailIntent]; the [source] extra is what distinguishes a widget + * tap (sets the base view) from a reminder tap (leaves it untouched). + */ + fun openEventIntent( + context: Context, + eventId: Long, + beginMillis: Long, + endMillis: Long, + source: CalendarView, + ): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply { + putExtra(EXTRA_SOURCE_VIEW, source.name) + } + /** Open the create-event form prefilled for [date] (home-screen widgets). */ fun openCreateIntent(context: Context, date: LocalDate): Intent = Intent(context, MainActivity::class.java).apply { 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 831eb26..2b3cb82 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 @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor( store.edit { it[WEEK_START_KEY] = pref.name } } + /** + * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — + * the historical hard-coded startup view — so existing users see no change + * until they pick another. Also the bottom of the in-app view back stack, so + * back from any other view returns here before exiting. + */ + val defaultView: Flow = store.data.map { prefs -> + prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week) + } + + suspend fun setDefaultView(view: CalendarView) { + store.edit { it[DEFAULT_VIEW_KEY] = 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 @@ -244,6 +259,7 @@ class SettingsPrefs @Inject constructor( internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val WEEK_START_KEY = stringPreferencesKey("week_start") + internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") 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 91063f8..8bc1c63 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,15 +13,20 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.common.viewBaseStack import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen @@ -35,14 +41,23 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock /** - * Holds the active top-level view (spec M1) and swaps between the calendar + * Holds the top-level view back stack (spec M1) and swaps between the calendar * screens. Each screen owns its own ViewModel and date anchor; the view-switcher * pill in their top bars writes back here via [onSelectView]. * + * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. + * A lateral move (pill / drawer / a widget establishing its own view) replaces + * the non-home top; a date tap drills the day view on top. Pressing back pops + * one level, and the base-level [BackHandler] hands off to the system to exit + * once only the home view remains. So back from a widget-opened screen returns + * to that widget's view, then home, then out. + * * [requestedDetailKey] is an externally requested occurrence (a tapped * reminder notification routed through MainActivity): it opens the detail * overlay exactly like an event tap and is cleared via [onDetailKeyConsumed] - * so a later recomposition can't re-open it. + * so a later recomposition can't re-open it. (A widget event tap instead arrives + * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the + * widget's view.) */ @Composable fun CalendarHost( @@ -53,15 +68,24 @@ fun CalendarHost( onWidgetNavConsumed: () -> Unit = {}, requestedImportUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}, + viewModel: CalendarHostViewModel = hiltViewModel(), ) { - var view by rememberSaveable { mutableStateOf(CalendarView.Week) } - val onSelectView: (CalendarView) -> Unit = { view = it } + // Wait for the persisted default view before seeding the stack, so the app + // opens straight on the user's choice instead of flashing a placeholder and + // correcting it. Brief blank first frame, matching the onboarding gate above. + val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return + + var viewStack by rememberSaveable(stateSaver = viewStackSaver) { + mutableStateOf(listOf(defaultView)) + } + val view = viewStack.last() + val onSelectView: (CalendarView) -> Unit = { viewStack = viewBaseStack(defaultView, it) } // Tapping a day in the month grid opens the day view anchored to that date. var pendingDayIso by rememberSaveable { mutableStateOf(null) } val onOpenDay: (LocalDate) -> Unit = { date -> pendingDayIso = date.toString() - view = CalendarView.Day + viewStack = viewStack.drillToDay() } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -156,17 +180,30 @@ fun CalendarHost( importForm = null } - // A home-screen widget launch asks to open a date (→ day view) or start a - // create. Handled once and cleared, mirroring [requestedDetailKey]. + // A home-screen widget launch asks to open a date (→ day view), open an + // event's detail, or start a create. Handled once and cleared, mirroring + // [requestedDetailKey]. Date/event opens root the stack in the widget's own + // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { - // Reveal the day view: drop any overlay that would cover it, so - // an external date-open doesn't land under an open Settings/form. + // Drill the day view in over the widget's view: drop any overlay + // that would cover it, so the open doesn't land under Settings/form. dismissCoveringOverlays() createDateIso = null pendingDayIso = req.dateIso - view = CalendarView.Day + viewStack = viewBaseStack(defaultView, req.source).drillToDay() + onWidgetNavConsumed() + } + is WidgetNavRequest.OpenEvent -> { + // Root the stack in the widget's view, then open the occurrence + // detail over it (same key shape as a tapped event / reminder). + dismissCoveringOverlays() + createDateIso = null + viewStack = viewBaseStack(defaultView, req.source) + val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis) + heldKey = key + detailKey = key onWidgetNavConsumed() } is WidgetNavRequest.Create -> { @@ -189,6 +226,16 @@ fun CalendarHost( val slideSpec = rememberCalendarSlideSpec() + // Base-level back: pop the view stack while no overlay covers it (each overlay + // owns its own BackHandler and takes precedence). Disabled at the home view, + // so back there falls through to the system and exits the app. + val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || + editKey != null || showSettings || showCalendars || importUri != null || + importForm != null + BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { + viewStack = viewStack.dropLast(1) + } + Box(modifier = modifier.fillMaxSize()) { when (view) { CalendarView.Week -> WeekScreen( @@ -338,3 +385,9 @@ fun CalendarHost( } } } + +/** Persists the view back stack across config change / process death by ordinal. */ +private val viewStackSaver = listSaver, Int>( + save = { stack -> stack.map(CalendarView::ordinal) }, + restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt new file mode 100644 index 0000000..9a9c2b7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.calendula.ui + +import androidx.lifecycle.ViewModel +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 kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +/** + * Supplies [CalendarHost] with the user's default startup view (M1). Held as a + * nullable [StateFlow] so the host can wait for DataStore's first emission before + * seeding its view back stack — rendering nothing for that first frame rather + * than flashing the old hard-coded Week view and then correcting it. + */ +@HiltViewModel +class CalendarHostViewModel @Inject constructor( + prefs: SettingsPrefs, +) : ViewModel() { + + val defaultView: StateFlow = prefs.defaultView.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) +} 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 2dcfe02..91889a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt @@ -1,14 +1,28 @@ package de.jeanlucmakiola.calendula.ui +import de.jeanlucmakiola.calendula.ui.common.CalendarView + /** * A navigation a home-screen widget asked the app to perform when launched. * Parsed from the launch intent in MainActivity and consumed once by - * [CalendarHost] (event taps reuse the existing reminder detail-key channel, so - * they are not modelled here). + * [CalendarHost]. Every request carries the [source] view of the widget it came + * from (the agenda widget → [CalendarView.Agenda], the month widget → + * [CalendarView.Month]) so the in-app back stack roots itself in that view: + * backing out of the opened date/event returns to the widget's own view, not the + * default home. (Reminder notifications are not widgets — they keep the separate + * detail-key channel and leave the base view untouched.) */ sealed interface WidgetNavRequest { - /** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */ - data class OpenDate(val dateIso: String) : WidgetNavRequest + /** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */ + data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest + + /** Open one occurrence's detail (an agenda-widget event tap), over [source]. */ + data class OpenEvent( + val eventId: Long, + val beginMillis: Long, + val endMillis: Long, + val source: CalendarView, + ) : WidgetNavRequest /** Open the create-event form prefilled for [dateIso] (today when null). */ data class Create(val dateIso: String?) : WidgetNavRequest 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 e942999..bcf25e2 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 @@ -49,3 +49,23 @@ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): Calend if (i < 0) return available.firstOrNull() ?: CalendarView.Month return available[(i + 1) % available.size] } + +/** + * The top-level view back stack (bottom → top): the [default] home view always + * sits at the bottom, with a laterally-selected or widget-entered view layered + * above it. Pressing back pops one level until only the home view remains, then + * the system exits the app. + * + * [viewBaseStack] is the stack after a lateral move to [top] (the switcher pill, + * the drawer, or a widget establishing its own view): just `[default]` when + * [top] is the home view, else `[default, top]`. It collapses any prior drill-in + * so the depth never grows past a single lateral level. [drillToDay] pushes the + * day view on top — a date tap from the month grid or a widget — unless the day + * view is already current. + */ +fun viewBaseStack(default: CalendarView, top: CalendarView): List = + if (top == default) listOf(default) else listOf(default, top) + +/** Push the day view as a drill-in over the current stack (no-op if already on it). */ +fun List.drillToDay(): List = + if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day 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 47e86ba..22b2f8e 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 @@ -90,6 +90,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport 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.labelRes import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.OptionPicker import de.jeanlucmakiola.calendula.ui.common.Position @@ -404,6 +406,7 @@ private fun AppearanceScreen( ) { var showTheme by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } + var showDefaultView by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -415,6 +418,12 @@ private fun AppearanceScreen( position = Position.Top, onClick = { showTheme = true }, ) + GroupedRow( + title = stringResource(R.string.settings_default_view), + summary = stringResource(state.defaultView.labelRes), + position = Position.Middle, + onClick = { showDefaultView = true }, + ) GroupedRow( title = stringResource(R.string.settings_dynamic_color), summary = if (state.dynamicColorAvailable) { @@ -464,6 +473,16 @@ private fun AppearanceScreen( onDismiss = { showWeekStart = false }, ) } + if (showDefaultView) { + OptionPicker( + title = stringResource(R.string.settings_default_view), + options = IMPLEMENTED_VIEWS, + selected = state.defaultView, + label = { stringResource(it.labelRes) }, + onSelect = viewModel::setDefaultView, + onDismiss = { showDefaultView = false }, + ) + } } @Composable 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 5d88985..1dac3bb 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 @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.common.CalendarView /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -17,6 +18,8 @@ data class SettingsUiState( val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.AUTO, + /** The calendar view the app opens on, and the home of the view back stack (M1). */ + val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, /** Whether Calendula posts reminder notifications (v1.4). */ 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 eead6f9..45a3160 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 @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - ) { base, defaults, overrides -> + prefs.defaultView, + ) { base, defaults, overrides, defaultView -> base.copy( + defaultView = defaultView, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setWeekStart(pref) } } + fun setDefaultView(view: CalendarView) { + viewModelScope.launch { prefs.setDefaultView(view) } + } + fun setFormFieldDefault(field: EventFormField, enabled: Boolean) { viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } } 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 09afee9..036ac13 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 @@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme @@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { modifier = GlanceModifier .fillMaxWidth() .padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp) - .clickable(actionStartActivity(MainActivity.openDateIntent(context, date))), + .clickable( + actionStartActivity( + MainActivity.openDateIntent(context, date, CalendarView.Agenda), + ), + ), ) } @@ -199,11 +204,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) { .padding(horizontal = 4.dp, vertical = 4.dp) .clickable( actionStartActivity( - MainActivity.eventDetailIntent( + MainActivity.openEventIntent( context = context, eventId = event.eventId, beginMillis = event.start.toEpochMilliseconds(), endMillis = event.end.toEpochMilliseconds(), + source = CalendarView.Agenda, ), ), ), 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 a650be7..10089fc 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 @@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks @@ -209,7 +210,9 @@ private fun MonthHeader(label: String) { resId = R.drawable.ic_widget_today, contentDescription = context.getString(R.string.widget_today), onClick = GlanceModifier.clickable( - actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))), + actionStartActivity( + MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month), + ), ), ) HeaderIcon( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a874c0..c3c6725 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -264,6 +264,7 @@ System Light Dark + Default view Dynamic colour Requires Android 12 or newer Week starts on @@ -299,7 +300,7 @@ App language System default - Theme, dynamic colour, week start + Theme, default view, week start Default fields for new events Event reminders About 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 new file mode 100644 index 0000000..a1e2c73 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The pure back-stack rules behind [CalendarHost]'s top-level navigation: + * the home view is always the bottom, a lateral move keeps the stack one deep, + * and a date tap drills the day view on top. Verifies the back sequences the + * widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home). + */ +class ViewBackStackTest { + + @Test + fun `lateral move to the home view collapses to just the home`() { + assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week)) + .containsExactly(CalendarView.Week) + } + + @Test + fun `lateral move to a non-home view layers it over the home`() { + assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month)) + .containsExactly(CalendarView.Week, CalendarView.Month) + .inOrder() + } + + @Test + fun `a lateral move never grows past one level above home`() { + // Switching pill targets in turn replaces the top rather than stacking. + val afterMonth = viewBaseStack(CalendarView.Week, CalendarView.Month) + val afterAgenda = viewBaseStack(CalendarView.Week, CalendarView.Agenda) + assertThat(afterMonth).hasSize(2) + assertThat(afterAgenda).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder() + } + + @Test + fun `drilling pushes the day view on top`() { + assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay()) + .containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day) + .inOrder() + } + + @Test + fun `drilling is a no-op when the day view is already current`() { + val stack = listOf(CalendarView.Week, CalendarView.Day) + assertThat(stack.drillToDay()).isEqualTo(stack) + } + + @Test + fun `agenda widget date tap backs out agenda then home`() { + // openDate(source = Agenda): root in Agenda over the Week home, then drill. + val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay() + assertThat(stack) + .containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day) + .inOrder() + // Pressing back pops one level at a time down to the home view. + assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder() + assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week) + } + + @Test + fun `month widget date tap backs out month then home`() { + val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay() + assertThat(stack.last()).isEqualTo(CalendarView.Day) + assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month) + assertThat(stack.first()).isEqualTo(CalendarView.Week) + } + + @Test + fun `a widget whose view is the home view does not duplicate the home`() { + // Default = Agenda, agenda widget event tap → just the home, no extra layer. + assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda)) + .containsExactly(CalendarView.Agenda) + } +} From 119a8afe8ef7b8f6466fcbfc0062ed6a97ccf749 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 08:17:23 +0200 Subject: [PATCH 2/5] feat(nav): interactive month widget + history-retracing back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device review follow-ups: - Month widget grid is now tappable. Day numbers open that day and event bars open the event's detail, both rooted in the month view so back returns to the grid. Previously only the prev/next/today header controls responded — the grid cells were never clickable. - Pill/drawer view switches now build a visit history instead of collapsing to the default view. Back retraces the views you moved through (a not-yet-visited view is pushed; revisiting one collapses the loop back to it), down to the default, then exits. Widget launches still reset to their own view context. Refs #1, #2 (reported by @devinside). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +++++---- .../calendula/ui/CalendarHost.kt | 14 ++++---- .../calendula/ui/common/CalendarView.kt | 30 ++++++++++------ .../calendula/widget/month/MonthWidget.kt | 29 +++++++++++++-- .../calendula/ui/common/ViewBackStackTest.kt | 35 ++++++++++++++----- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c94340..0cc686e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Appearance) lets you pick Month, Week, Day or Agenda as the view shown each time you start the app, instead of always opening on Week. Thanks to @devinside for the suggestion ([#1]). +- The month widget is now interactive: tap any day to open it, or tap an event to + open its details. Previously only the month's prev/next/today controls + responded. Thanks to @devinside for spotting this ([#2]). ### Fixed -- Home-screen widgets now back out the way you came in. Opening a day or event - from the Agenda widget keeps you in the agenda context, and the Month widget - keeps you in the month context — pressing Back returns to that view instead of - dropping you on the week view. The app now keeps a proper view history: Back - steps from a drilled-in day to the view you opened it from, then to your - default view, then leaves the app. Thanks to @devinside for reporting ([#2]). +- Home-screen widgets now back out the way you came in. A day or event opened + from the Agenda widget keeps you in the agenda context, and from the Month + widget in the month context — pressing Back returns there instead of dropping + you on the week view. More broadly, the app now keeps a real view history: + switching views and drilling into a day are retraced by Back one step at a + time, down to your default view before the app exits. Thanks to @devinside for + reporting ([#2]). ## [2.8.0] — 2026-06-23 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 8bc1c63..d57b04b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.viewBaseStack import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen @@ -46,11 +47,12 @@ import kotlin.time.Clock * pill in their top bars writes back here via [onSelectView]. * * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. - * A lateral move (pill / drawer / a widget establishing its own view) replaces - * the non-home top; a date tap drills the day view on top. Pressing back pops - * one level, and the base-level [BackHandler] hands off to the system to exit - * once only the home view remains. So back from a widget-opened screen returns - * to that widget's view, then home, then out. + * A lateral switch (pill / drawer) builds a visit history so back retraces it + * (see [selectView]); a widget launch resets the stack to its own view; a date + * tap drills the day view on top. Pressing back pops one level, and the + * base-level [BackHandler] hands off to the system to exit once only the home + * view remains. So back from a widget-opened screen returns to that widget's + * view, then home; and back through pill switches walks the views in reverse. * * [requestedDetailKey] is an externally requested occurrence (a tapped * reminder notification routed through MainActivity): it opens the detail @@ -79,7 +81,7 @@ fun CalendarHost( mutableStateOf(listOf(defaultView)) } val view = viewStack.last() - val onSelectView: (CalendarView) -> Unit = { viewStack = viewBaseStack(defaultView, it) } + val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } // Tapping a day in the month grid opens the day view anchored to that date. var pendingDayIso by rememberSaveable { mutableStateOf(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 bcf25e2..57151ab 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 @@ -52,19 +52,27 @@ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): Calend /** * The top-level view back stack (bottom → top): the [default] home view always - * sits at the bottom, with a laterally-selected or widget-entered view layered - * above it. Pressing back pops one level until only the home view remains, then - * the system exits the app. + * sits at the bottom. Pressing back pops one level until only the home view + * remains, then the system exits the app. Three operations move it: * - * [viewBaseStack] is the stack after a lateral move to [top] (the switcher pill, - * the drawer, or a widget establishing its own view): just `[default]` when - * [top] is the home view, else `[default, top]`. It collapses any prior drill-in - * so the depth never grows past a single lateral level. [drillToDay] pushes the - * day view on top — a date tap from the month grid or a widget — unless the day - * view is already current. + * - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit + * history so back retraces it: switching to a not-yet-visited view pushes it on + * top; switching to one already in the stack pops back to it (collapsing the + * loop, so the depth stays bounded by the number of distinct views). Selecting + * the home view collapses to just `[default]`. + * - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when + * the widget's view is the home view, else `[default, source]`. A widget entry + * starts a fresh context rather than extending in-app history. + * - [drillToDay] — pushes the day view on top (a date tap from the month grid or + * a widget), unless the day view is already current. */ -fun viewBaseStack(default: CalendarView, top: CalendarView): List = - if (top == default) listOf(default) else listOf(default, top) +fun List.selectView(target: CalendarView): List { + val existing = indexOf(target) + return if (existing >= 0) take(existing + 1) else this + target +} + +fun viewBaseStack(default: CalendarView, source: CalendarView): List = + if (source == default) listOf(default) else listOf(default, source) /** Push the day view as a drill-in over the current stack (no-op if already on it). */ fun List.drillToDay(): List = 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 10089fc..1799bd5 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 @@ -303,8 +303,16 @@ private fun WeekRow( @Composable private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { + val context = LocalContext.current Box( - modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT), + 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)), + ), contentAlignment = Alignment.Center, ) { Box( @@ -356,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { @Composable private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { val context = LocalContext.current - Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) { + Box( + modifier = GlanceModifier + .width(width) + .height(LANE_HEIGHT) + .padding(horizontal = 1.dp) + // Tap an event bar to open its detail, rooted in the month view. + .clickable( + actionStartActivity( + MainActivity.openEventIntent( + context = context, + eventId = event.eventId, + beginMillis = event.start.toEpochMilliseconds(), + endMillis = event.end.toEpochMilliseconds(), + source = CalendarView.Month, + ), + ), + ), + ) { Box( modifier = GlanceModifier .fillMaxSize() 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 a1e2c73..7c4f118 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 @@ -12,25 +12,44 @@ import org.junit.jupiter.api.Test class ViewBackStackTest { @Test - fun `lateral move to the home view collapses to just the home`() { + fun `a widget whose view is the home view resets to just the home`() { assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week)) .containsExactly(CalendarView.Week) } @Test - fun `lateral move to a non-home view layers it over the home`() { + fun `a widget launch resets to its view over the home`() { assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month)) .containsExactly(CalendarView.Week, CalendarView.Month) .inOrder() } @Test - fun `a lateral move never grows past one level above home`() { - // Switching pill targets in turn replaces the top rather than stacking. - val afterMonth = viewBaseStack(CalendarView.Week, CalendarView.Month) - val afterAgenda = viewBaseStack(CalendarView.Week, CalendarView.Agenda) - assertThat(afterMonth).hasSize(2) - assertThat(afterAgenda).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder() + fun `pill switches build a visit history that back retraces`() { + // Agenda → Day → Week → Month, each a new view, stacks the full history. + val stack = listOf(CalendarView.Agenda) + .selectView(CalendarView.Day) + .selectView(CalendarView.Week) + .selectView(CalendarView.Month) + assertThat(stack).containsExactly( + CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month, + ).inOrder() + // Back walks the views in reverse, not straight to the home view. + assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week) + } + + @Test + fun `selecting an already-visited view collapses the loop back to it`() { + val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day) + .selectView(CalendarView.Agenda) + assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder() + } + + @Test + fun `selecting the home view collapses to just the home`() { + val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day) + .selectView(CalendarView.Week) + assertThat(stack).containsExactly(CalendarView.Week) } @Test From c6d15d011829720e20317d4a8eaa9fdf83856acf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 09:19:35 +0200 Subject: [PATCH 3/5] feat(ui): consistent motion polish, predictive back & reduced-motion support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise the app's motion vocabulary in CalendarTransitions.kt and route every surface through it so animation is consistent app-wide: - Shared expand/collapse, list-item and fade-through helpers; the event-edit expand pattern is now the shared one (no duplicate). - Settings reminder-override rows and the reminder Custom field now expand/ collapse instead of bare-fading. - Search and agenda rows animate (fade/relocate) via animateItem. - Month/week/day slide and the onboarding gates honour the new helpers. - View switching (month/week/day/agenda) now fades through instead of snapping — lateral navigation per M3, while in-view paging keeps its slide. Add full predictive-back support: - enableOnBackInvokedCallback in the manifest. - New Modifier.predictiveBack(onBack) drives the standard preview transform (scale/shift/round) following the back gesture; applied to detail, edit, search, settings (+ sub-screens & calendar manager via CollapsingScaffold), the calendar editor and import — each keeping its existing back semantics. Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS "remove animations" setting, which Compose ignores by default) collapses spatial motion to a quick fade and skips the back preview. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 1 + .../calendula/ui/CalendarHost.kt | 80 +++++--- .../jeanlucmakiola/calendula/ui/RootScreen.kt | 60 +++--- .../calendula/ui/agenda/AgendaScreen.kt | 4 + .../calendula/ui/calendars/CalendarsScreen.kt | 5 +- .../ui/common/CalendarTransitions.kt | 193 +++++++++++++++++- .../calendula/ui/common/GroupedList.kt | 3 +- .../calendula/ui/common/Picker.kt | 6 +- .../calendula/ui/day/DayScreen.kt | 6 +- .../calendula/ui/detail/EventDetailScreen.kt | 5 +- .../calendula/ui/edit/EventEditScreen.kt | 19 +- .../calendula/ui/imports/ImportScreen.kt | 7 +- .../calendula/ui/month/MonthScreen.kt | 6 +- .../calendula/ui/search/SearchScreen.kt | 10 +- .../calendula/ui/settings/SettingsScreen.kt | 8 +- .../calendula/ui/week/WeekScreen.kt | 6 +- 16 files changed, 324 insertions(+), 95 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d91eb70..04a21a8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ WeekScreen( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) - CalendarView.Day -> DayScreen( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - initialDateIso = pendingDayIso, - ) - CalendarView.Month -> MonthScreen( - selectedView = view, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) - CalendarView.Agenda -> AgendaScreen( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) + // Switching between the peer views (month/week/day/agenda) is lateral + // navigation, so it fades through rather than sliding — paging *within* a + // view keeps the directional slide. AnimatedContent keyed on the view type. + val viewSwitch = calendarFadeThrough() + AnimatedContent( + targetState = view, + transitionSpec = { viewSwitch }, + label = "view-switch", + ) { currentView -> + when (currentView) { + CalendarView.Week -> WeekScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + CalendarView.Day -> DayScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + initialDateIso = pendingDayIso, + ) + CalendarView.Month -> MonthScreen( + selectedView = currentView, + onSelectView = onSelectView, + onOpenDay = onOpenDay, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + CalendarView.Agenda -> AgendaScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + } } // Search overlay — below detail/edit in the Box so a tapped result's diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index aae4d82..1ab1f0f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui import android.Manifest import android.content.pm.PackageManager +import androidx.compose.animation.Crossfade +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf @@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RootScreen( modifier: Modifier = Modifier, @@ -51,32 +55,40 @@ fun RootScreen( onDispose { lifecycle.removeObserver(obs) } } - if (hasPermission) { - // Second onboarding gate (v1.4, one-time): reminder notifications. - // Null until DataStore's first emission — render nothing for that - // frame instead of flashing the wrong screen. - val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() - val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() - when (onboardingDone) { - true -> CalendarHost( - modifier = modifier, - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = onDetailKeyConsumed, - widgetNavRequest = widgetNavRequest, - onWidgetNavConsumed = onWidgetNavConsumed, - requestedImportUri = requestedImportUri, - onImportConsumed = onImportConsumed, - ) - false -> ReminderOnboardingScreen( - onFinished = reminderOnboarding::finish, + // Cross-fade the one-time onboarding gates so granting permission / finishing + // onboarding eases into the next screen instead of snapping. A fade carries no + // spatial motion, so it stays appropriate under "remove animations" too. + val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec() + Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted -> + if (granted) { + // Second onboarding gate (v1.4, one-time): reminder notifications. + // Null until DataStore's first emission — render nothing for that + // frame instead of flashing the wrong screen. + val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() + val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() + Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done -> + when (done) { + true -> CalendarHost( + modifier = modifier, + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = onDetailKeyConsumed, + widgetNavRequest = widgetNavRequest, + onWidgetNavConsumed = onWidgetNavConsumed, + requestedImportUri = requestedImportUri, + onImportConsumed = onImportConsumed, + ) + false -> ReminderOnboardingScreen( + onFinished = reminderOnboarding::finish, + modifier = modifier, + ) + null -> {} + } + } + } else { + PermissionScreen( + onGranted = { hasPermission = true }, modifier = modifier, ) - null -> {} } - } else { - PermissionScreen( - onGranted = { hasPermission = true }, - modifier = modifier, - ) } } 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 ef4acbb..ee72a1b 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 @@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure @@ -190,6 +191,7 @@ private fun AgendaList( AgendaEventRow( event = event, position = positionOf(index, day.events.size), + modifier = calendarAnimateItem(), onClick = { onEventClick(event) }, ) } @@ -221,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) { private fun AgendaEventRow( event: EventInstance, position: Position, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( + modifier = modifier, title = title, summary = agendaTimeSummary(event), position = position, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index c0f02b1..5ef5a75 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -4,7 +4,6 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow @@ -295,10 +295,9 @@ private fun CalendarEditor( var confirmDelete by remember { mutableStateOf(false) } val dark = isSystemInDarkTheme() - BackHandler(onBack = onClose) - Scaffold( modifier = Modifier + .predictiveBack(onBack = onClose) .fillMaxSize() .background(MaterialTheme.colorScheme.surface), topBar = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 34e60b3..acc1132 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,14 +1,56 @@ package de.jeanlucmakiola.calendula.ui.common +import android.provider.Settings +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.coroutines.cancellation.CancellationException + +/** + * Whether the user has asked the system to remove animations (Settings → + * Accessibility → "Remove animations", which sets the global animator duration + * scale to 0). Compose animations do *not* honour this platform flag on their + * own, so the shared motion helpers in this file check it and fall back to a + * quick cross-fade — opacity only, no spatial movement — to respect the + * vestibular intent of the setting while keeping state changes legible. + * + * Read once at composition; the scale changes rarely and only takes full effect + * after a process restart anyway. + */ +@Composable +fun rememberReduceMotion(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} /** * The M3 Expressive spatial spring used for the month/week slide: the *fast* @@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.fastSpatialSpec() /** - * Horizontal slide for navigating between adjacent months/weeks. + * The fast effects spec from the active motion scheme, for opacity (fade) + * transitions. Captured in composable scope alongside [rememberCalendarSlideSpec] + * for use in non-composable transition lambdas and as the reduced-motion fallback. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberCalendarFadeSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.fastEffectsSpec() + +/** + * Horizontal slide for navigating between adjacent months/weeks/days. * - * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump - * (e.g. "today"); a jump reuses the forward direction. - * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. + * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump + * (e.g. "today"); a jump reuses the forward direction. + * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. + * @param fadeSpec effects spec for the reduced-motion fade, typically + * [rememberCalendarFadeSpec]. + * @param reduceMotion when true, swap the directional slide for a plain cross-fade. */ fun calendarSlideTransition( slideDir: Int, spec: FiniteAnimationSpec, + fadeSpec: FiniteAnimationSpec, + reduceMotion: Boolean, ): ContentTransform { + if (reduceMotion) { + return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) + } val dir = if (slideDir == 0) 1 else slideDir return slideInHorizontally(spec) { w -> dir * w } .togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) } + +/** + * Cross-fade [ContentTransform] for swapping whole screens or content blocks + * where there is no meaningful spatial direction (e.g. onboarding gates). Pure + * opacity, so it doubles as its own reduced-motion form. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarFadeThrough(): ContentTransform { + val fade = MaterialTheme.motionScheme.fastEffectsSpec() + return fadeIn(fade).togetherWith(fadeOut(fade)) +} + +/** + * Enter transition for a vertically-revealed section (expandable rows, inline + * fields): height grows from the top while fading in. Under reduced motion the + * height growth is dropped, leaving a quick fade. + * + * Pair with [calendarCollapseExit]. This is the promoted form of the pattern + * originally inlined in the event edit form, so every expandable surface in the + * app reveals the same way. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade + } +} + +/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition { + val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade + } +} + +/** + * Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility` + * (e.g. search results once a query resolves): a gentle rise + fade. Reduced + * motion keeps the fade only. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 } + } +} + +/** + * Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the + * app's motion scheme instead of Compose's default spring. Returns a bare + * [Modifier] under reduced motion so rows snap into place. Requires the list to + * supply stable item keys. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier = + if (reduceMotion) { + Modifier + } else { + Modifier.animateItem( + fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + ) + } + +/** + * Standard Android predictive-back transform for a full-screen overlay: as the + * back gesture is dragged, the surface scales toward ~90%, shifts toward the + * swiped edge and rounds its corners, previewing what's behind. Completing the + * gesture invokes [onBack]; cancelling springs it back. + * + * A drop-in replacement for a screen's own `BackHandler(onBack)` — register it + * once and apply the returned [Modifier] to that screen's root so the preview + * respects the same back semantics. Under reduced motion the visual preview is + * skipped (the back still works); on API < 34 the system delivers no progress, + * so it degrades to a plain back. + */ +@Composable +fun Modifier.predictiveBack( + onBack: () -> Unit, + enabled: Boolean = true, + reduceMotion: Boolean = rememberReduceMotion(), +): Modifier { + val progress = remember { Animatable(0f) } + var fromLeftEdge by remember { mutableStateOf(true) } + + PredictiveBackHandler(enabled = enabled) { events -> + try { + events.collect { event -> + fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT + progress.snapTo(FastOutSlowInEasing.transform(event.progress)) + } + onBack() + progress.snapTo(0f) + } catch (_: CancellationException) { + progress.animateTo(0f) + } + } + + if (reduceMotion) return this + return this.graphicsLayer { + val p = progress.value + val scale = 1f - 0.1f * p + scaleX = scale + scaleY = scale + translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p + shape = RoundedCornerShape(32.dp.toPx() * p) + clip = p > 0f + } +} 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 445e38b..0b33440 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 @@ -1,6 +1,5 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -73,11 +72,11 @@ fun CollapsingScaffold( actions: @Composable RowScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit, ) { - BackHandler(onBack = onBack) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = modifier + .predictiveBack(onBack = onBack) .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .nestedScroll(scrollBehavior.nestedScrollConnection), 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 4bde29c..d5d72d2 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 @@ -173,7 +173,11 @@ fun ReminderDefaultPicker( }, onClick = { customExpanded = !customExpanded }, ) - AnimatedVisibility(visible = customExpanded) { + AnimatedVisibility( + visible = customExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { CustomReminderEditor( amountText = amountText, onAmountChange = { amountText = it }, 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 775e07b..6354122 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 @@ -75,6 +75,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -237,6 +239,8 @@ private fun DayContent( val threshold = with(density) { 24.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() // Hoisted above the per-day AnimatedContent so the vertical scroll position // survives day-to-day swipes. We only centre on noon once, on first entry @@ -287,7 +291,7 @@ private fun DayContent( DayUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "day-transition", ) { s -> when (s) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index c3e4d0c..35dc26b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -92,6 +91,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -132,8 +132,6 @@ fun EventDetailScreen( val state by viewModel.state.collectAsStateWithLifecycle() val deleteState by viewModel.deleteState.collectAsStateWithLifecycle() - BackHandler(onBack = onBack) - val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -214,6 +212,7 @@ fun EventDetailScreen( } Scaffold( + modifier = Modifier.predictiveBack(onBack = onBack), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 88fb610..b29a75d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -8,14 +8,9 @@ import android.content.pm.PackageManager import android.net.Uri import android.provider.ContactsContract import android.util.Patterns -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow @@ -202,8 +199,6 @@ fun EventEditScreen( viewModel.reset() onClose() } - BackHandler(onBack = close) - // The event vanished between the detail screen and the edit tap — fall // back to the detail screen, which shows its own failure state. LaunchedEffect(loadFailed) { @@ -253,6 +248,7 @@ fun EventEditScreen( } Scaffold( + modifier = Modifier.predictiveBack(onBack = close), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( @@ -410,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } * spring open with the expressive spatial spec instead of popping in. * (Initially-visible sections render without animating.) */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun OptionalFormSection( visible: Boolean, @@ -418,10 +413,8 @@ private fun OptionalFormSection( ) { AnimatedVisibility( visible = visible, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), ) { Column(modifier = Modifier.fillMaxWidth(), content = content) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 92e152e..881b836 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.imports import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.OptionCard /** @@ -57,7 +57,6 @@ fun ImportScreen( ) { LaunchedEffect(uri) { viewModel.load(uri) } val state by viewModel.state.collectAsStateWithLifecycle() - BackHandler(onBack = onClose) // A single event isn't shown here — it opens the create form for review. LaunchedEffect(state) { @@ -65,7 +64,9 @@ fun ImportScreen( } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .predictiveBack(onBack = onClose) + .fillMaxSize(), topBar = { TopAppBar( title = { Text(stringResource(R.string.import_title)) }, 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 f2d5b28..d5a1ca4 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 @@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -217,6 +219,8 @@ private fun MonthContent( val threshold = with(density) { 6.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() val swipeModifier = Modifier.pointerInput(Unit) { detectHorizontalDragGestures( @@ -243,7 +247,7 @@ private fun MonthContent( MonthUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "month-transition", ) { s -> when (s) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 5309844..17d467f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.calendula.ui.search -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -49,6 +48,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position @@ -88,10 +89,8 @@ fun SearchScreen( focusRequester.requestFocus() keyboard?.show() } - BackHandler(onBack = onBack) - Scaffold( - modifier = modifier, + modifier = modifier.predictiveBack(onBack = onBack), containerColor = MaterialTheme.colorScheme.surface, topBar = { TopAppBar( @@ -174,6 +173,7 @@ private fun SearchResults( SearchResultRow( event = event, position = positionOf(index, events.size), + modifier = calendarAnimateItem(), onClick = { onEventClick(event) }, ) } @@ -184,10 +184,12 @@ private fun SearchResults( private fun SearchResultRow( event: EventInstance, position: Position, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() GroupedRow( + modifier = modifier, title = event.title, summary = searchSummary(event), position = position, 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 22b2f8e..5863ebd 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 @@ -88,6 +88,8 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -674,7 +676,11 @@ private fun NotificationsScreen( } }, ) - AnimatedVisibility(visible = expanded) { + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { Column { val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) GroupedRow( 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 66f9aeb..8d9a311 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 @@ -80,6 +80,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -247,6 +249,8 @@ private fun WeekContent( val threshold = with(density) { 24.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() // Hoisted above the per-week AnimatedContent so the vertical scroll position // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on @@ -300,7 +304,7 @@ private fun WeekContent( WeekUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "week-transition", ) { s -> when (s) { From 5d48c90408dae781a013fba3a6fb43e0e781abc1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 09:20:45 +0200 Subject: [PATCH 4/5] docs(changelog): note motion polish, predictive back & reduced-motion Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc686e..3c9b93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The month widget is now interactive: tap any day to open it, or tap an event to open its details. Previously only the month's prev/next/today controls responded. Thanks to @devinside for spotting this ([#2]). +- Predictive back. Swiping back from a screen — an event, the editor, search or + settings — now follows your finger, shrinking the screen to preview what's + behind so you can see where Back will take you before you let go; slide it back + to cancel. The preview needs Android 14 or newer; on older versions Back works + as before. +- Respect for "Remove animations". If you've turned animations off in your + device's Accessibility settings, Calendula now honours it everywhere: motion + collapses to a quick fade and the back preview is skipped. + +### Changed +- Smoother, more consistent motion throughout. Expanding sections, list updates + in search and agenda, and the onboarding screens now animate the same way + across the app, instead of some places sliding or growing while others popped + in. Switching between Month, Week, Day and Agenda now cross-fades rather than + snapping, while paging within a view keeps its slide. ### Fixed - Home-screen widgets now back out the way you came in. A day or event opened From 024deadf78350121a77106605a9dbbcec2a788a2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 09:56:54 +0200 Subject: [PATCH 5/5] chore(release): prepare v2.9.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- app/build.gradle.kts | 4 +-- .../android/en-US/changelogs/20900.txt | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/20900.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9b93b..207b925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.9.0] — 2026-06-25 ### Added - Choose the view Calendula opens on. A new **Default view** setting (Settings → diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a076a4..3a1f441 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 20800 - versionName = "2.8.0" + versionCode = 20900 + versionName = "2.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/20900.txt b/fastlane/metadata/android/en-US/changelogs/20900.txt new file mode 100644 index 0000000..0d80610 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20900.txt @@ -0,0 +1,33 @@ +### Added +- Choose the view Calendula opens on. A new **Default view** setting (Settings → + Appearance) lets you pick Month, Week, Day or Agenda as the view shown each + time you start the app, instead of always opening on Week. Thanks to + @devinside for the suggestion ([#1]). +- The month widget is now interactive: tap any day to open it, or tap an event to + open its details. Previously only the month's prev/next/today controls + responded. Thanks to @devinside for spotting this ([#2]). +- Predictive back. Swiping back from a screen — an event, the editor, search or + settings — now follows your finger, shrinking the screen to preview what's + behind so you can see where Back will take you before you let go; slide it back + to cancel. The preview needs Android 14 or newer; on older versions Back works + as before. +- Respect for "Remove animations". If you've turned animations off in your + device's Accessibility settings, Calendula now honours it everywhere: motion + collapses to a quick fade and the back preview is skipped. + +### Changed +- Smoother, more consistent motion throughout. Expanding sections, list updates + in search and agenda, and the onboarding screens now animate the same way + across the app, instead of some places sliding or growing while others popped + in. Switching between Month, Week, Day and Agenda now cross-fades rather than + snapping, while paging within a view keeps its slide. + +### Fixed +- Home-screen widgets now back out the way you came in. A day or event opened + from the Agenda widget keeps you in the agenda context, and from the Month + widget in the month context — pressing Back returns there instead of dropping + you on the week view. More broadly, the app now keeps a real view history: + switching views and drilling into a day are retraced by Back one step at a + time, down to your default view before the app exits. Thanks to @devinside for + reporting ([#2]). +