diff --git a/CHANGELOG.md b/CHANGELOG.md index 8882c81..207b925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ 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). +## [2.9.0] — 2026-06-25 + +### Added +- Choose the view Calendula opens on. A new **Default view** setting (Settings → + Appearance) lets you pick Month, Week, Day or Agenda as the view shown each + time you start the app, instead of always opening on Week. Thanks to + @devinside for the suggestion ([#1]). +- The month widget is now interactive: tap any day to open it, or tap an event to + open its details. Previously only the month's prev/next/today controls + responded. Thanks to @devinside for spotting this ([#2]). +- Predictive back. Swiping back from a screen — an event, the editor, search or + settings — now follows your finger, shrinking the screen to preview what's + behind so you can see where Back will take you before you let go; slide it back + to cancel. The preview needs Android 14 or newer; on older versions Back works + as before. +- Respect for "Remove animations". If you've turned animations off in your + device's Accessibility settings, Calendula now honours it everywhere: motion + collapses to a quick fade and the back preview is skipped. + +### Changed +- Smoother, more consistent motion throughout. Expanding sections, list updates + in search and agenda, and the onboarding screens now animate the same way + across the app, instead of some places sliding or growing while others popped + in. Switching between Month, Week, Day and Agenda now cross-fades rather than + snapping, while paging within a view keeps its slide. + +### Fixed +- Home-screen widgets now back out the way you came in. A day or event opened + from the Agenda widget keeps you in the agenda context, and from the Month + widget in the month context — pressing Back returns there instead of dropping + you on the week view. More broadly, the app now keeps a real view history: + switching views and drilling into a day are retraced by Back one step at a + time, down to your default view before the app exits. Thanks to @devinside for + reporting ([#2]). + ## [2.8.0] — 2026-06-23 ### Added @@ -572,3 +607,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/build.gradle.kts b/app/build.gradle.kts index 0a076a4..3a1f441 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 20800 - versionName = "2.8.0" + versionCode = 20900 + versionName = "2.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d91eb70..04a21a8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ 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..d300334 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,7 @@ package de.jeanlucmakiola.calendula.ui +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,15 +14,22 @@ 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.calendarFadeThrough 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 import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen @@ -35,14 +44,24 @@ 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 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 * 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 +72,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 = viewStack.selectView(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 +184,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,41 +230,61 @@ 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( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) - CalendarView.Day -> DayScreen( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - initialDateIso = pendingDayIso, - ) - CalendarView.Month -> MonthScreen( - selectedView = view, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) - CalendarView.Agenda -> AgendaScreen( - selectedView = view, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - ) + // Switching between the peer views (month/week/day/agenda) is lateral + // navigation, so it fades through rather than sliding — paging *within* a + // view keeps the directional slide. AnimatedContent keyed on the view type. + val viewSwitch = calendarFadeThrough() + AnimatedContent( + targetState = view, + transitionSpec = { viewSwitch }, + label = "view-switch", + ) { currentView -> + when (currentView) { + CalendarView.Week -> WeekScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + CalendarView.Day -> DayScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + initialDateIso = pendingDayIso, + ) + CalendarView.Month -> MonthScreen( + selectedView = currentView, + onSelectView = onSelectView, + onOpenDay = onOpenDay, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + CalendarView.Agenda -> AgendaScreen( + selectedView = currentView, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + ) + } } // Search overlay — below detail/edit in the Box so a tapped result's @@ -338,3 +399,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/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index aae4d82..1ab1f0f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui import android.Manifest import android.content.pm.PackageManager +import androidx.compose.animation.Crossfade +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf @@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RootScreen( modifier: Modifier = Modifier, @@ -51,32 +55,40 @@ fun RootScreen( onDispose { lifecycle.removeObserver(obs) } } - if (hasPermission) { - // Second onboarding gate (v1.4, one-time): reminder notifications. - // Null until DataStore's first emission — render nothing for that - // frame instead of flashing the wrong screen. - val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() - val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() - when (onboardingDone) { - true -> CalendarHost( - modifier = modifier, - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = onDetailKeyConsumed, - widgetNavRequest = widgetNavRequest, - onWidgetNavConsumed = onWidgetNavConsumed, - requestedImportUri = requestedImportUri, - onImportConsumed = onImportConsumed, - ) - false -> ReminderOnboardingScreen( - onFinished = reminderOnboarding::finish, + // Cross-fade the one-time onboarding gates so granting permission / finishing + // onboarding eases into the next screen instead of snapping. A fade carries no + // spatial motion, so it stays appropriate under "remove animations" too. + val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec() + Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted -> + if (granted) { + // Second onboarding gate (v1.4, one-time): reminder notifications. + // Null until DataStore's first emission — render nothing for that + // frame instead of flashing the wrong screen. + val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() + val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() + Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done -> + when (done) { + true -> CalendarHost( + modifier = modifier, + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = onDetailKeyConsumed, + widgetNavRequest = widgetNavRequest, + onWidgetNavConsumed = onWidgetNavConsumed, + requestedImportUri = requestedImportUri, + onImportConsumed = onImportConsumed, + ) + false -> ReminderOnboardingScreen( + onFinished = reminderOnboarding::finish, + modifier = modifier, + ) + null -> {} + } + } + } else { + PermissionScreen( + onGranted = { hasPermission = true }, modifier = modifier, ) - null -> {} } - } else { - PermissionScreen( - onGranted = { hasPermission = true }, - modifier = modifier, - ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/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/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ef4acbb..ee72a1b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure @@ -190,6 +191,7 @@ private fun AgendaList( AgendaEventRow( event = event, position = positionOf(index, day.events.size), + modifier = calendarAnimateItem(), onClick = { onEventClick(event) }, ) } @@ -221,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) { private fun AgendaEventRow( event: EventInstance, position: Position, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( + modifier = modifier, title = title, summary = agendaTimeSummary(event), position = position, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index c0f02b1..5ef5a75 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -4,7 +4,6 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow @@ -295,10 +295,9 @@ private fun CalendarEditor( var confirmDelete by remember { mutableStateOf(false) } val dark = isSystemInDarkTheme() - BackHandler(onBack = onClose) - Scaffold( modifier = Modifier + .predictiveBack(onBack = onClose) .fillMaxSize() .background(MaterialTheme.colorScheme.surface), topBar = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 34e60b3..acc1132 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,14 +1,56 @@ package de.jeanlucmakiola.calendula.ui.common +import android.provider.Settings +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.coroutines.cancellation.CancellationException + +/** + * Whether the user has asked the system to remove animations (Settings → + * Accessibility → "Remove animations", which sets the global animator duration + * scale to 0). Compose animations do *not* honour this platform flag on their + * own, so the shared motion helpers in this file check it and fall back to a + * quick cross-fade — opacity only, no spatial movement — to respect the + * vestibular intent of the setting while keeping state changes legible. + * + * Read once at composition; the scale changes rarely and only takes full effect + * after a process restart anyway. + */ +@Composable +fun rememberReduceMotion(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} /** * The M3 Expressive spatial spring used for the month/week slide: the *fast* @@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.fastSpatialSpec() /** - * Horizontal slide for navigating between adjacent months/weeks. + * The fast effects spec from the active motion scheme, for opacity (fade) + * transitions. Captured in composable scope alongside [rememberCalendarSlideSpec] + * for use in non-composable transition lambdas and as the reduced-motion fallback. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberCalendarFadeSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.fastEffectsSpec() + +/** + * Horizontal slide for navigating between adjacent months/weeks/days. * - * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump - * (e.g. "today"); a jump reuses the forward direction. - * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. + * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump + * (e.g. "today"); a jump reuses the forward direction. + * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. + * @param fadeSpec effects spec for the reduced-motion fade, typically + * [rememberCalendarFadeSpec]. + * @param reduceMotion when true, swap the directional slide for a plain cross-fade. */ fun calendarSlideTransition( slideDir: Int, spec: FiniteAnimationSpec, + fadeSpec: FiniteAnimationSpec, + reduceMotion: Boolean, ): ContentTransform { + if (reduceMotion) { + return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) + } val dir = if (slideDir == 0) 1 else slideDir return slideInHorizontally(spec) { w -> dir * w } .togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) } + +/** + * Cross-fade [ContentTransform] for swapping whole screens or content blocks + * where there is no meaningful spatial direction (e.g. onboarding gates). Pure + * opacity, so it doubles as its own reduced-motion form. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarFadeThrough(): ContentTransform { + val fade = MaterialTheme.motionScheme.fastEffectsSpec() + return fadeIn(fade).togetherWith(fadeOut(fade)) +} + +/** + * Enter transition for a vertically-revealed section (expandable rows, inline + * fields): height grows from the top while fading in. Under reduced motion the + * height growth is dropped, leaving a quick fade. + * + * Pair with [calendarCollapseExit]. This is the promoted form of the pattern + * originally inlined in the event edit form, so every expandable surface in the + * app reveals the same way. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade + } +} + +/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition { + val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade + } +} + +/** + * Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility` + * (e.g. search results once a query resolves): a gentle rise + fade. Reduced + * motion keeps the fade only. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) { + fade + } else { + fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 } + } +} + +/** + * Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the + * app's motion scheme instead of Compose's default spring. Returns a bare + * [Modifier] under reduced motion so rows snap into place. Requires the list to + * supply stable item keys. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier = + if (reduceMotion) { + Modifier + } else { + Modifier.animateItem( + fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + ) + } + +/** + * Standard Android predictive-back transform for a full-screen overlay: as the + * back gesture is dragged, the surface scales toward ~90%, shifts toward the + * swiped edge and rounds its corners, previewing what's behind. Completing the + * gesture invokes [onBack]; cancelling springs it back. + * + * A drop-in replacement for a screen's own `BackHandler(onBack)` — register it + * once and apply the returned [Modifier] to that screen's root so the preview + * respects the same back semantics. Under reduced motion the visual preview is + * skipped (the back still works); on API < 34 the system delivers no progress, + * so it degrades to a plain back. + */ +@Composable +fun Modifier.predictiveBack( + onBack: () -> Unit, + enabled: Boolean = true, + reduceMotion: Boolean = rememberReduceMotion(), +): Modifier { + val progress = remember { Animatable(0f) } + var fromLeftEdge by remember { mutableStateOf(true) } + + PredictiveBackHandler(enabled = enabled) { events -> + try { + events.collect { event -> + fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT + progress.snapTo(FastOutSlowInEasing.transform(event.progress)) + } + onBack() + progress.snapTo(0f) + } catch (_: CancellationException) { + progress.animateTo(0f) + } + } + + if (reduceMotion) return this + return this.graphicsLayer { + val p = progress.value + val scale = 1f - 0.1f * p + scaleX = scale + scaleY = scale + translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p + shape = RoundedCornerShape(32.dp.toPx() * p) + clip = p > 0f + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index e942999..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 @@ -49,3 +49,31 @@ 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. Pressing back pops one level until only the home view + * remains, then the system exits the app. Three operations move it: + * + * - [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 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 = + if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index 445e38b..0b33440 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -73,11 +72,11 @@ fun CollapsingScaffold( actions: @Composable RowScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit, ) { - BackHandler(onBack = onBack) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = modifier + .predictiveBack(onBack = onBack) .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 4bde29c..d5d72d2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -173,7 +173,11 @@ fun ReminderDefaultPicker( }, onClick = { customExpanded = !customExpanded }, ) - AnimatedVisibility(visible = customExpanded) { + AnimatedVisibility( + visible = customExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { CustomReminderEditor( amountText = amountText, onAmountChange = { amountText = it }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 775e07b..6354122 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -75,6 +75,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -237,6 +239,8 @@ private fun DayContent( val threshold = with(density) { 24.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() // Hoisted above the per-day AnimatedContent so the vertical scroll position // survives day-to-day swipes. We only centre on noon once, on first entry @@ -287,7 +291,7 @@ private fun DayContent( DayUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "day-transition", ) { s -> when (s) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index c3e4d0c..35dc26b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -92,6 +91,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -132,8 +132,6 @@ fun EventDetailScreen( val state by viewModel.state.collectAsStateWithLifecycle() val deleteState by viewModel.deleteState.collectAsStateWithLifecycle() - BackHandler(onBack = onBack) - val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -214,6 +212,7 @@ fun EventDetailScreen( } Scaffold( + modifier = Modifier.predictiveBack(onBack = onBack), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 88fb610..b29a75d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -8,14 +8,9 @@ import android.content.pm.PackageManager import android.net.Uri import android.provider.ContactsContract import android.util.Patterns -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow @@ -202,8 +199,6 @@ fun EventEditScreen( viewModel.reset() onClose() } - BackHandler(onBack = close) - // The event vanished between the detail screen and the edit tap — fall // back to the detail screen, which shows its own failure state. LaunchedEffect(loadFailed) { @@ -253,6 +248,7 @@ fun EventEditScreen( } Scaffold( + modifier = Modifier.predictiveBack(onBack = close), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( @@ -410,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } * spring open with the expressive spatial spec instead of popping in. * (Initially-visible sections render without animating.) */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun OptionalFormSection( visible: Boolean, @@ -418,10 +413,8 @@ private fun OptionalFormSection( ) { AnimatedVisibility( visible = visible, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), ) { Column(modifier = Modifier.fillMaxWidth(), content = content) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 92e152e..881b836 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.imports import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.OptionCard /** @@ -57,7 +57,6 @@ fun ImportScreen( ) { LaunchedEffect(uri) { viewModel.load(uri) } val state by viewModel.state.collectAsStateWithLifecycle() - BackHandler(onBack = onClose) // A single event isn't shown here — it opens the create form for review. LaunchedEffect(state) { @@ -65,7 +64,9 @@ fun ImportScreen( } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .predictiveBack(onBack = onClose) + .fillMaxSize(), topBar = { TopAppBar( title = { Text(stringResource(R.string.import_title)) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f2d5b28..d5a1ca4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -217,6 +219,8 @@ private fun MonthContent( val threshold = with(density) { 6.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() val swipeModifier = Modifier.pointerInput(Unit) { detectHorizontalDragGestures( @@ -243,7 +247,7 @@ private fun MonthContent( MonthUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "month-transition", ) { s -> when (s) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 5309844..17d467f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.calendula.ui.search -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -49,6 +48,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem +import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position @@ -88,10 +89,8 @@ fun SearchScreen( focusRequester.requestFocus() keyboard?.show() } - BackHandler(onBack = onBack) - Scaffold( - modifier = modifier, + modifier = modifier.predictiveBack(onBack = onBack), containerColor = MaterialTheme.colorScheme.surface, topBar = { TopAppBar( @@ -174,6 +173,7 @@ private fun SearchResults( SearchResultRow( event = event, position = positionOf(index, events.size), + modifier = calendarAnimateItem(), onClick = { onEventClick(event) }, ) } @@ -184,10 +184,12 @@ private fun SearchResults( private fun SearchResultRow( event: EventInstance, position: Position, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() GroupedRow( + modifier = modifier, title = event.title, summary = searchSummary(event), position = position, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 47e86ba..5863ebd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -88,8 +88,12 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +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 +408,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 +420,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 +475,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 @@ -655,7 +676,11 @@ private fun NotificationsScreen( } }, ) - AnimatedVisibility(visible = expanded) { + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { Column { val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) GroupedRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/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/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 66f9aeb..8d9a311 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -80,6 +80,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -247,6 +249,8 @@ private fun WeekContent( val threshold = with(density) { 24.dp.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() // Hoisted above the per-week AnimatedContent so the vertical scroll position // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on @@ -300,7 +304,7 @@ private fun WeekContent( WeekUiState.Loading -> "loading" } }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "week-transition", ) { s -> when (s) { 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..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 @@ -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( @@ -300,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( @@ -353,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/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..7c4f118 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -0,0 +1,94 @@ +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 `a widget whose view is the home view resets to just the home`() { + assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week)) + .containsExactly(CalendarView.Week) + } + + @Test + 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 `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 + 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) + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/20900.txt b/fastlane/metadata/android/en-US/changelogs/20900.txt new file mode 100644 index 0000000..0d80610 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20900.txt @@ -0,0 +1,33 @@ +### Added +- Choose the view Calendula opens on. A new **Default view** setting (Settings → + Appearance) lets you pick Month, Week, Day or Agenda as the view shown each + time you start the app, instead of always opening on Week. Thanks to + @devinside for the suggestion ([#1]). +- The month widget is now interactive: tap any day to open it, or tap an event to + open its details. Previously only the month's prev/next/today controls + responded. Thanks to @devinside for spotting this ([#2]). +- Predictive back. Swiping back from a screen — an event, the editor, search or + settings — now follows your finger, shrinking the screen to preview what's + behind so you can see where Back will take you before you let go; slide it back + to cancel. The preview needs Android 14 or newer; on older versions Back works + as before. +- Respect for "Remove animations". If you've turned animations off in your + device's Accessibility settings, Calendula now honours it everywhere: motion + collapses to a quick fade and the back preview is skipped. + +### Changed +- Smoother, more consistent motion throughout. Expanding sections, list updates + in search and agenda, and the onboarding screens now animate the same way + across the app, instead of some places sliding or growing while others popped + in. Switching between Month, Week, Day and Agenda now cross-fades rather than + snapping, while paging within a view keeps its slide. + +### Fixed +- Home-screen widgets now back out the way you came in. A day or event opened + from the Agenda widget keeps you in the agenda context, and from the Month + widget in the month context — pressing Back returns there instead of dropping + you on the week view. More broadly, the app now keeps a real view history: + switching views and drilling into a day are retraced by Back one step at a + time, down to your default view before the app exits. Thanks to @devinside for + reporting ([#2]). +