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