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) <noreply@anthropic.com>
This commit is contained in:
19
CHANGELOG.md
19
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/),
|
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).
|
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
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### 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
|
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||||
- `.planning/` project-tracking documents
|
- `.planning/` project-tracking documents
|
||||||
|
|
||||||
|
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||||
|
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
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.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
val source = sourceViewOrNull()
|
||||||
// can't carry typed extras, so the action alone signals create-on-today.
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
return when {
|
||||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
else -> null
|
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? {
|
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)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
if (eventId == -1L) return null
|
if (eventId == -1L) return null
|
||||||
return longArrayOf(
|
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_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
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/
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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 {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
data = "calendula://date/$date".toUri()
|
data = "calendula://date/$date".toUri()
|
||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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). */
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
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<CalendarView> = 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
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
* "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 THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
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 FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -12,15 +13,20 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
@@ -35,14 +41,23 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
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
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* 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
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
* reminder notification routed through MainActivity): it opens the detail
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
* 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
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
@@ -53,15 +68,24 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
// 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.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
pendingDayIso = date.toString()
|
||||||
view = CalendarView.Day
|
viewStack = viewStack.drillToDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -156,17 +180,30 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// 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) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Reveal the day view: drop any overlay that would cover it, so
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// an external date-open doesn't land under an open Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
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()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
@@ -189,6 +226,16 @@ fun CalendarHost(
|
|||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (view) {
|
when (view) {
|
||||||
CalendarView.Week -> WeekScreen(
|
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<List<CalendarView>, Int>(
|
||||||
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
|
)
|
||||||
|
|||||||
@@ -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<CalendarView?> = prefs.defaultView.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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.
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
* Parsed from the launch intent in MainActivity and consumed once by
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||||
* they are not modelled here).
|
* 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 {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
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). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|||||||
@@ -49,3 +49,23 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||||
return available[(i + 1) % available.size]
|
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<CalendarView> =
|
||||||
|
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<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
|||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
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.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -404,6 +406,7 @@ private fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -415,6 +418,12 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showTheme = true },
|
onClick = { showTheme = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dynamic_color),
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
summary = if (state.dynamicColorAvailable) {
|
summary = if (state.dynamicColorAvailable) {
|
||||||
@@ -464,6 +473,16 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showWeekStart = false },
|
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
|
@Composable
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -17,6 +18,8 @@ data class SettingsUiState(
|
|||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
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"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { overrides, allDayOverrides, calendars ->
|
) { overrides, allDayOverrides, calendars ->
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
},
|
},
|
||||||
) { base, defaults, overrides ->
|
prefs.defaultView,
|
||||||
|
) { base, defaults, overrides, defaultView ->
|
||||||
base.copy(
|
base.copy(
|
||||||
|
defaultView = defaultView,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.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)
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.eventDetailIntent(
|
MainActivity.openEventIntent(
|
||||||
context = context,
|
context = context,
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
beginMillis = event.start.toEpochMilliseconds(),
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
endMillis = event.end.toEpochMilliseconds(),
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
source = CalendarView.Agenda,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
|||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</string>
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_default_view">Default view</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
<string name="settings_week_start">Week starts on</string>
|
<string name="settings_week_start">Week starts on</string>
|
||||||
@@ -299,7 +300,7 @@
|
|||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user