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/2] 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/2] 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