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) + } +}