From 4ffeed840904740fb79daf22345f0b23db92f13e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 21:44:13 +0200 Subject: [PATCH] Split the view step in two and add back navigation (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default view and the month style are now a step each, and both are built from the same live-preview-over-grouped-rows shape as the Settings choosers they duplicate, instead of chips. The month style is asked whatever view was picked — Month is reachable from the drawer regardless, and making the step conditional would move the counter on the step right before it. Back is a button in the step's top bar and the system gesture, both clearing the previous step's answer; the plan derives position from those answers, so that is all the navigation there is. The calendar grant has nothing to return to, so back stops there. The slide runs backwards when it does. --- .../calendula/data/prefs/SettingsPrefs.kt | 26 +- .../jeanlucmakiola/calendula/ui/RootScreen.kt | 16 +- .../calendula/ui/onboarding/BackupStep.kt | 2 + .../calendula/ui/onboarding/OnboardingPlan.kt | 27 +- .../ui/onboarding/OnboardingSteps.kt | 47 +++- .../ui/onboarding/OnboardingViewModel.kt | 27 +- .../calendula/ui/onboarding/ReminderStep.kt | 2 + .../calendula/ui/onboarding/ViewStep.kt | 265 +++++++++++------- app/src/main/res/values/strings.xml | 4 +- .../ui/onboarding/OnboardingPlanTest.kt | 59 +++- docs/ARCHITECTURE.md | 4 +- floret-kit | 2 +- 12 files changed, 354 insertions(+), 127 deletions(-) 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 907559f..a0ccc9f 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 @@ -550,8 +550,9 @@ class SettingsPrefs @Inject constructor( prefs[REMINDER_ONBOARDING_KEY] ?: false } - suspend fun setReminderOnboardingDone() { - store.edit { it[REMINDER_ONBOARDING_KEY] = true } + /** Cleared, not just set: the wizard's back button re-asks a step (#163). */ + suspend fun setReminderOnboardingDone(done: Boolean = true) { + store.edit { it[REMINDER_ONBOARDING_KEY] = done } } /** @@ -575,17 +576,26 @@ class SettingsPrefs @Inject constructor( prefs[ONBOARDING_BACKUP_KEY] ?: false } - suspend fun setOnboardingBackupDone() { - store.edit { it[ONBOARDING_BACKUP_KEY] = true } + suspend fun setOnboardingBackupDone(done: Boolean = true) { + store.edit { it[ONBOARDING_BACKUP_KEY] = done } } - /** Whether the wizard's view step has been answered (or skipped). */ + /** Whether the wizard's default-view step has been answered (or skipped). */ val onboardingViewDone: Flow = store.data.map { prefs -> prefs[ONBOARDING_VIEW_KEY] ?: false } - suspend fun setOnboardingViewDone() { - store.edit { it[ONBOARDING_VIEW_KEY] = true } + suspend fun setOnboardingViewDone(done: Boolean = true) { + store.edit { it[ONBOARDING_VIEW_KEY] = done } + } + + /** Whether the wizard's month-style step has been answered (or skipped). */ + val onboardingMonthStyleDone: Flow = store.data.map { prefs -> + prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false + } + + suspend fun setOnboardingMonthStyleDone(done: Boolean = true) { + store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done } } /** @@ -919,6 +929,8 @@ class SettingsPrefs @Inject constructor( internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed") internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done") internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done") + internal val ONBOARDING_MONTH_STYLE_KEY = + booleanPreferencesKey("onboarding_month_style_done") internal val ALLOW_COLOR_UNSUPPORTED_KEY = booleanPreferencesKey("allow_color_unsupported_calendars") internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") 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 a0ac174..05edef1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -116,6 +118,18 @@ fun RootScreen( CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss) } + // Which way the flow is moving, so a step-back slides back. The app sits + // after every step and the blank first frame before them, so finishing the + // wizard reads as one more move forward. + val ordinal = when (target) { + RootTarget.Loading -> Int.MIN_VALUE + RootTarget.App -> Int.MAX_VALUE + is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0 + } + var lastOrdinal by remember { mutableIntStateOf(ordinal) } + val slideDir = if (ordinal < lastOrdinal) -1 else 1 + SideEffect { lastOrdinal = ordinal } + // The wizard advances along M3's shared-axis X, the same transition adjacent // months and weeks use: the outgoing step slides and fades one way while the // next arrives from the other. A plain cross-fade read as a smear here — @@ -133,7 +147,7 @@ fun RootScreen( fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) } else { calendarSlideTransition( - slideDir = 1, + slideDir = slideDir, spec = slideSpec, fadeSpec = fadeSpec, reduceMotion = reduceMotion, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt index 2611731..ae6fa23 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt @@ -38,6 +38,7 @@ internal fun BackupStep( onSkip: () -> Unit, modifier: Modifier = Modifier, progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, ) { val pickFolder = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree(), @@ -50,6 +51,7 @@ internal fun BackupStep( OnboardingScaffold( modifier = modifier, progress = progress, + navigationIcon = navigationIcon, topSpacing = OnboardingSpace.lg, hero = { IconHero(Icons.Filled.CloudOff) }, actions = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt index 64e78dc..60c27f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt @@ -11,8 +11,11 @@ enum class OnboardingStep { /** Only when nothing you write to is synced anywhere — offers a backup. */ Backup, - /** Default view + month style, chosen from live previews. */ + /** Which view the app opens on, chosen from a live preview. */ View, + + /** How the Month view lays itself out, chosen from a live preview. */ + MonthStyle, } /** @@ -31,6 +34,16 @@ data class OnboardingPlan( /** A one-step flow is not a wizard: there is nothing to count down. */ val showsProgress: Boolean get() = total > 1 + + /** The step before [current], or null at the start (and once finished). */ + val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null } + + /** + * Whether the wizard can step back. Everything is re-askable except the + * calendar grant, which belongs to the system — once given there is nothing + * for a back press to return to. + */ + val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission } /** @@ -46,6 +59,11 @@ data class OnboardingPlan( * whole permission step. The backup step is assumed to apply until proven * otherwise, so the flow can only ever get shorter — never sprout a step the * counter had not accounted for. + * + * The month-style step is *not* conditional on Month being the chosen view: + * Month is reachable from the drawer whatever opens first, and making the step + * appear and disappear as the view is picked would move the counter under the + * user on the step right before it. */ fun onboardingPlan( hasPermission: Boolean, @@ -53,6 +71,7 @@ fun onboardingPlan( wizardArmed: Boolean, backupDone: Boolean, viewDone: Boolean, + monthStyleDone: Boolean, backupApplies: Boolean?, ): OnboardingPlan { val fresh = wizardArmed || (!hasPermission && !remindersDone) @@ -60,7 +79,10 @@ fun onboardingPlan( if (!hasPermission || fresh) add(OnboardingStep.Permission) if (!remindersDone || fresh) add(OnboardingStep.Reminders) if (fresh && backupApplies != false) add(OnboardingStep.Backup) - if (fresh) add(OnboardingStep.View) + if (fresh) { + add(OnboardingStep.View) + add(OnboardingStep.MonthStyle) + } } val current = steps.firstOrNull { step -> when (step) { @@ -68,6 +90,7 @@ fun onboardingPlan( OnboardingStep.Reminders -> !remindersDone OnboardingStep.Backup -> !backupDone OnboardingStep.View -> !viewDone + OnboardingStep.MonthStyle -> !monthStyleDone } } return OnboardingPlan(steps, current) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt index a3bd295..68e76ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt @@ -1,5 +1,10 @@ package de.jeanlucmakiola.calendula.ui.onboarding +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -20,9 +25,6 @@ fun OnboardingSteps( onPermissionGranted: () -> Unit, modifier: Modifier = Modifier, ) { - // Positioned from the step being drawn, not from the plan's own current - // step: mid-crossfade the outgoing screen would otherwise jump to the - // incoming one's number. // Coerced because the outgoing half of a transition may be a step the live // plan has since dropped — the backup step goes once the calendars say it // does not apply — and a "Step 0 of 3" flash is worse than a stale number. @@ -39,6 +41,22 @@ fun OnboardingSteps( } } + // The system back gesture does the same thing as the button, and stays off + // on the first step so back still leaves the app. + BackHandler(enabled = plan.canGoBack) { viewModel.goBack() } + val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) { + null + } else { + { + IconButton(onClick = viewModel::goBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.onboarding_back), + ) + } + } + } + when (step) { OnboardingStep.Permission -> PermissionScreen( onGranted = onPermissionGranted, @@ -49,25 +67,40 @@ fun OnboardingSteps( onFinished = viewModel::finishReminders, modifier = modifier, progress = progress, + navigationIcon = navigationIcon, ) OnboardingStep.Backup -> BackupStep( onEnable = viewModel::enableAutoBackup, onSkip = viewModel::skipBackup, modifier = modifier, progress = progress, + navigationIcon = navigationIcon, ) + // The choice is null only until DataStore's first emission; rendering + // nothing for that frame beats a preview built on the wrong defaults. OnboardingStep.View -> { - // Null only until DataStore's first emission; rendering nothing for - // that frame beats a preview built on the wrong defaults. val choice by viewModel.viewChoice.collectAsStateWithLifecycle() choice?.let { ViewStep( choice = it, - onSelectView = viewModel::setDefaultView, - onSelectMonthStyle = viewModel::setMonthViewStyle, + onSelect = viewModel::setDefaultView, onFinished = viewModel::finishView, modifier = modifier, progress = progress, + navigationIcon = navigationIcon, + ) + } + } + OnboardingStep.MonthStyle -> { + val choice by viewModel.viewChoice.collectAsStateWithLifecycle() + choice?.let { + MonthStyleStep( + choice = it, + onSelect = viewModel::setMonthViewStyle, + onFinished = viewModel::finishMonthStyle, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 8169a87..9de1d1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -74,6 +74,7 @@ class OnboardingViewModel @Inject constructor( prefs.onboardingWizardArmed, prefs.onboardingBackupDone, prefs.onboardingViewDone, + prefs.onboardingMonthStyleDone, ::OnboardingFlags, ) @@ -86,6 +87,7 @@ class OnboardingViewModel @Inject constructor( wizardArmed = stored.wizardArmed, backupDone = stored.backupDone, viewDone = stored.viewDone, + monthStyleDone = stored.monthStyleDone, backupApplies = backup, ) } @@ -132,6 +134,24 @@ class OnboardingViewModel @Inject constructor( } } + /** + * Re-ask the step before the current one, by clearing its answer. The plan + * derives the position from those answers, so un-setting one *is* the back + * navigation. The calendar grant belongs to the system and cannot be + * returned to, which [OnboardingPlan.canGoBack] already refuses. + */ + fun goBack() { + viewModelScope.launch { + when (plan.value?.previous) { + OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false) + OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false) + OnboardingStep.View -> prefs.setOnboardingViewDone(false) + OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false) + OnboardingStep.Permission, null -> Unit + } + } + } + /** Close the backup step without setting anything up. */ fun skipBackup() { viewModelScope.launch { prefs.setOnboardingBackupDone() } @@ -172,10 +192,14 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setMonthViewStyle(style) } } - /** Close the view step — the choices themselves are saved as they are made. */ + /** Close a picker step — the choice itself is saved as it is made. */ fun finishView() { viewModelScope.launch { prefs.setOnboardingViewDone() } } + + fun finishMonthStyle() { + viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } + } } /** The stored answers the plan is derived from. */ @@ -184,6 +208,7 @@ private data class OnboardingFlags( val wizardArmed: Boolean, val backupDone: Boolean, val viewDone: Boolean, + val monthStyleDone: Boolean, ) /** What the view step starts from, and renders its previews with. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt index 8af40d2..ef53c5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt @@ -40,6 +40,7 @@ fun ReminderStep( onFinished: (remindersEnabled: Boolean) -> Unit, modifier: Modifier = Modifier, progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, ) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), @@ -48,6 +49,7 @@ fun ReminderStep( OnboardingScaffold( modifier = modifier, progress = progress, + navigationIcon = navigationIcon, topSpacing = OnboardingSpace.lg, hero = { IconHero(Icons.Filled.NotificationsActive) }, actions = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index 2498897..d472419 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -4,16 +4,14 @@ import androidx.compose.animation.Crossfade import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,13 +20,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.day.DayViewPreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview @@ -36,46 +36,149 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingSpace +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale /** - * Last wizard step (#163): which view the app opens on, chosen by looking at it - * rather than by reading four names and guessing. + * Wizard step: which view the app opens on (#163). * - * One preview canvas serves both choices — when Month is the pick, a second row - * of chips for the month style appears under it and the same canvas re-renders. - * Showing the month style on its own second preview would just be the picture - * above it twice. - * - * Selections apply as they are made, so there is nothing to confirm: the single - * action closes the step, and pressing it without touching anything is what - * skipping means — the current defaults are pre-selected. + * Built like the Settings pickers it mirrors — a live preview over connected + * grouped rows — so the choice is made by looking rather than by reading four + * names and guessing, and so the same choice looks the same wherever it is + * made. Selecting applies immediately and leaves the step open; the preview + * above is the confirmation. */ -@OptIn(ExperimentalLayoutApi::class) @Composable internal fun ViewStep( choice: ViewChoice, - onSelectView: (CalendarView) -> Unit, - onSelectMonthStyle: (MonthViewStyle) -> Unit, + onSelect: (CalendarView) -> Unit, onFinished: () -> Unit, modifier: Modifier = Modifier, progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, ) { - val reduceMotion = rememberReduceMotion() val weekStart = choice.weekStart.resolveFirstDay(currentLocale()) + StepPickerScaffold( + title = stringResource(R.string.onboarding_view_title), + onFinished = onFinished, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) { + StepPreview(selected = choice.defaultView) { view -> + when (view) { + CalendarView.Month -> MonthStylePreview( + style = choice.monthViewStyle, + weekStart = weekStart, + height = PREVIEW_HEIGHT, + ) + CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT) + CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT) + CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) + } + } + IMPLEMENTED_VIEWS.forEachIndexed { index, view -> + val isSelected = view == choice.defaultView + GroupedRow( + title = stringResource(view.labelRes), + position = positionOf(index, IMPLEMENTED_VIEWS.size), + selected = isSelected, + leading = { + Icon( + imageVector = view.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onSelect(view) }, + ) + } + } +} + +/** + * Wizard step: how the Month view lays itself out (#163). The same shape as + * [ViewStep] and as the Settings chooser this duplicates, down to the selected + * style's own blurb sitting under its preview rather than on every row. + */ +@Composable +internal fun MonthStyleStep( + choice: ViewChoice, + onSelect: (MonthViewStyle) -> Unit, + onFinished: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + val weekStart = choice.weekStart.resolveFirstDay(currentLocale()) + val options = MonthViewStyle.entries + + StepPickerScaffold( + title = stringResource(R.string.onboarding_month_style_title), + onFinished = onFinished, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) { + StepPreview(selected = choice.monthViewStyle) { style -> + MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT) + } + PickerDescription(stringResource(choice.monthViewStyle.descriptionRes)) + options.forEachIndexed { index, style -> + val isSelected = style == choice.monthViewStyle + GroupedRow( + title = stringResource(style.labelRes), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onSelect(style) }, + ) + } + } +} + +/** + * The shell both picker steps share: the wizard chrome around a title and a + * full-bleed column, since grouped rows carry their own inset. + */ +@Composable +private fun StepPickerScaffold( + title: String, + onFinished: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, + body: @Composable ColumnScope.() -> Unit, +) { OnboardingScaffold( modifier = modifier, progress = progress, - topSpacing = OnboardingSpace.sm, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.xs, + contentPadding = 0.dp, hero = { Text( - text = stringResource(R.string.onboarding_view_title), + text = title, style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset), ) }, actions = { @@ -89,92 +192,44 @@ internal fun ViewStep( ) } }, + body = body, + ) +} + +/** The live preview, cross-fading as [selected] changes, framed like the picker's. */ +@Composable +private fun StepPreview(selected: T, content: @Composable (T) -> Unit) { + val reduceMotion = rememberReduceMotion() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .height(PREVIEW_HEIGHT), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier.fillMaxWidth().height(PREVIEW_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Crossfade( - targetState = choice.defaultView to choice.monthViewStyle, - animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), - label = "view-preview", - ) { (view, style) -> - // Each preview renders the real view at phone size and scales it - // down, so its own corners are square — the frame rounds it. - Box( - modifier = Modifier - .clip(PREVIEW_SHAPE) - .background(MaterialTheme.colorScheme.surface) - .clipToBounds(), - ) { - when (view) { - CalendarView.Month -> MonthStylePreview( - style = style, - weekStart = weekStart, - height = PREVIEW_HEIGHT, - ) - CalendarView.Week -> WeekViewPreview( - weekStart = weekStart, - height = PREVIEW_HEIGHT, - ) - CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT) - CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) - } - } + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = "step-preview", + ) { shown -> + // Each preview renders the real view at phone size and scales it + // down, so its own corners are square — the frame rounds it. + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + content(shown) } } - - Spacer(Modifier.height(OnboardingSpace.sm)) - ChipRow( - options = IMPLEMENTED_VIEWS, - selected = choice.defaultView, - label = { stringResource(it.labelRes) }, - onSelect = onSelectView, - ) - - // The month style only means anything once Month is the view being - // opened, so it stays out of the way until then. - if (choice.defaultView == CalendarView.Month) { - Spacer(Modifier.height(OnboardingSpace.xs)) - ChipRow( - options = MonthViewStyle.entries, - selected = choice.monthViewStyle, - label = { stringResource(it.labelRes) }, - onSelect = onSelectMonthStyle, - ) - Spacer(Modifier.height(OnboardingSpace.xs)) - Text( - text = stringResource(choice.monthViewStyle.descriptionRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } } } -/** One row of single-choice chips, wrapping onto a second line where it must. */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun ChipRow( - options: List, - selected: T, - label: @Composable (T) -> String, - onSelect: (T) -> Unit, -) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - ) { - options.forEach { option -> - FilterChip( - selected = option == selected, - onClick = { onSelect(option) }, - label = { Text(label(option)) }, - ) - } - } -} - -private val PREVIEW_HEIGHT = 260.dp +/** + * Smaller than the Settings chooser's preview: this screen also carries the step + * counter and the Continue button, and the whole step has to fit without + * scrolling. + */ +private val PREVIEW_HEIGHT: Dp = 200.dp private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ead759..559831e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -282,8 +282,10 @@ Calendula exports your local calendars in the background. Change how often in Settings. Choose folder and back up Not now - What should Calendula open on? + What should open first? + How should Month look? Continue + Back Event reminders diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt index 69b06cf..411e433 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt @@ -16,6 +16,7 @@ class OnboardingPlanTest { wizardArmed: Boolean = false, backupDone: Boolean = false, viewDone: Boolean = false, + monthStyleDone: Boolean = false, backupApplies: Boolean? = null, ) = onboardingPlan( hasPermission = hasPermission, @@ -23,6 +24,7 @@ class OnboardingPlanTest { wizardArmed = wizardArmed, backupDone = backupDone, viewDone = viewDone, + monthStyleDone = monthStyleDone, backupApplies = backupApplies, ) @@ -44,6 +46,7 @@ class OnboardingPlanTest { OnboardingStep.Reminders, OnboardingStep.Backup, OnboardingStep.View, + OnboardingStep.MonthStyle, ).inOrder() assertThat(granted.current).isEqualTo(OnboardingStep.Reminders) assertThat(granted.index).isEqualTo(2) @@ -59,7 +62,7 @@ class OnboardingPlanTest { ) assertThat(synced.steps).doesNotContain(OnboardingStep.Backup) assertThat(synced.current).isEqualTo(OnboardingStep.View) - assertThat(synced.total).isEqualTo(3) + assertThat(synced.total).isEqualTo(4) } @Test @@ -101,9 +104,63 @@ class OnboardingPlanTest { wizardArmed = true, backupDone = true, viewDone = true, + monthStyleDone = true, backupApplies = true, ) assertThat(done.current).isNull() assertThat(done.index).isEqualTo(0) } + + @Test + fun `the month style is asked whatever view was chosen`() { + // Month is reachable from the drawer whatever opens first, and making + // the step conditional would move the counter on the step before it. + val afterView = plan( + hasPermission = true, + wizardArmed = true, + remindersDone = true, + backupDone = true, + viewDone = true, + backupApplies = true, + ) + assertThat(afterView.current).isEqualTo(OnboardingStep.MonthStyle) + } + + @Test + fun `back steps to the previous answered step`() { + val onView = plan( + hasPermission = true, + wizardArmed = true, + remindersDone = true, + backupDone = true, + backupApplies = true, + ) + assertThat(onView.current).isEqualTo(OnboardingStep.View) + assertThat(onView.previous).isEqualTo(OnboardingStep.Backup) + assertThat(onView.canGoBack).isTrue() + } + + @Test + fun `back is refused where the previous step is the system grant`() { + // The permission is the system's to give; there is nothing to return to. + val onReminders = plan(hasPermission = true, wizardArmed = true, backupApplies = true) + assertThat(onReminders.previous).isEqualTo(OnboardingStep.Permission) + assertThat(onReminders.canGoBack).isFalse() + } + + @Test + fun `back is refused on the first step and once the flow is done`() { + assertThat(plan().canGoBack).isFalse() + val done = plan( + hasPermission = true, + remindersDone = true, + wizardArmed = true, + backupDone = true, + viewDone = true, + monthStyleDone = true, + backupApplies = true, + ) + assertThat(done.previous).isNull() + assertThat(done.canGoBack).isFalse() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fa9b021..6e09e19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,7 +94,9 @@ pure function of the stored answers that returns the whole flow *plus* the step it is on, and `RootScreen` renders that step. Two rules make it work: - The plan keeps **completed** steps, so finishing one never renumbers the - counter under the user. + counter under the user, and stepping *back* is just clearing the previous + step's answer — there is no separate back stack. The calendar grant is the + one step with nothing to return to, so back stops there. - The optional steps (backup, view) only exist for an install that went through the grant in-app — recorded by `armOnboardingWizard()`, which refuses to arm if the reminder step was already answered, so an existing diff --git a/floret-kit b/floret-kit index a0d6e45..80ed899 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit a0d6e458a9dc49feced6b967ff6d4c27ae2a4169 +Subproject commit 80ed899b55e0da03df98a97d4520800c71fc3315