Split the view step in two and add back navigation (#163)

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.
This commit is contained in:
2026-08-08 21:44:13 +02:00
parent 4601967370
commit 4ffeed8409
12 changed files with 354 additions and 127 deletions

View File

@@ -550,8 +550,9 @@ class SettingsPrefs @Inject constructor(
prefs[REMINDER_ONBOARDING_KEY] ?: false prefs[REMINDER_ONBOARDING_KEY] ?: false
} }
suspend fun setReminderOnboardingDone() { /** Cleared, not just set: the wizard's back button re-asks a step (#163). */
store.edit { it[REMINDER_ONBOARDING_KEY] = true } 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 prefs[ONBOARDING_BACKUP_KEY] ?: false
} }
suspend fun setOnboardingBackupDone() { suspend fun setOnboardingBackupDone(done: Boolean = true) {
store.edit { it[ONBOARDING_BACKUP_KEY] = 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<Boolean> = store.data.map { prefs -> val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_VIEW_KEY] ?: false prefs[ONBOARDING_VIEW_KEY] ?: false
} }
suspend fun setOnboardingViewDone() { suspend fun setOnboardingViewDone(done: Boolean = true) {
store.edit { it[ONBOARDING_VIEW_KEY] = true } store.edit { it[ONBOARDING_VIEW_KEY] = done }
}
/** Whether the wizard's month-style step has been answered (or skipped). */
val onboardingMonthStyleDone: Flow<Boolean> = 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_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done") internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_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 = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars") booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")

View File

@@ -9,6 +9,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -116,6 +118,18 @@ fun RootScreen(
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss) 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 // 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 // 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 — // 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)) fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
} else { } else {
calendarSlideTransition( calendarSlideTransition(
slideDir = 1, slideDir = slideDir,
spec = slideSpec, spec = slideSpec,
fadeSpec = fadeSpec, fadeSpec = fadeSpec,
reduceMotion = reduceMotion, reduceMotion = reduceMotion,

View File

@@ -38,6 +38,7 @@ internal fun BackupStep(
onSkip: () -> Unit, onSkip: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null, progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) { ) {
val pickFolder = rememberLauncherForActivityResult( val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(), contract = ActivityResultContracts.OpenDocumentTree(),
@@ -50,6 +51,7 @@ internal fun BackupStep(
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CloudOff) }, hero = { IconHero(Icons.Filled.CloudOff) },
actions = { actions = {

View File

@@ -11,8 +11,11 @@ enum class OnboardingStep {
/** Only when nothing you write to is synced anywhere — offers a backup. */ /** Only when nothing you write to is synced anywhere — offers a backup. */
Backup, Backup,
/** Default view + month style, chosen from live previews. */ /** Which view the app opens on, chosen from a live preview. */
View, 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. */ /** A one-step flow is not a wizard: there is nothing to count down. */
val showsProgress: Boolean get() = total > 1 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 * 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 * otherwise, so the flow can only ever get shorter — never sprout a step the
* counter had not accounted for. * 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( fun onboardingPlan(
hasPermission: Boolean, hasPermission: Boolean,
@@ -53,6 +71,7 @@ fun onboardingPlan(
wizardArmed: Boolean, wizardArmed: Boolean,
backupDone: Boolean, backupDone: Boolean,
viewDone: Boolean, viewDone: Boolean,
monthStyleDone: Boolean,
backupApplies: Boolean?, backupApplies: Boolean?,
): OnboardingPlan { ): OnboardingPlan {
val fresh = wizardArmed || (!hasPermission && !remindersDone) val fresh = wizardArmed || (!hasPermission && !remindersDone)
@@ -60,7 +79,10 @@ fun onboardingPlan(
if (!hasPermission || fresh) add(OnboardingStep.Permission) if (!hasPermission || fresh) add(OnboardingStep.Permission)
if (!remindersDone || fresh) add(OnboardingStep.Reminders) if (!remindersDone || fresh) add(OnboardingStep.Reminders)
if (fresh && backupApplies != false) add(OnboardingStep.Backup) 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 -> val current = steps.firstOrNull { step ->
when (step) { when (step) {
@@ -68,6 +90,7 @@ fun onboardingPlan(
OnboardingStep.Reminders -> !remindersDone OnboardingStep.Reminders -> !remindersDone
OnboardingStep.Backup -> !backupDone OnboardingStep.Backup -> !backupDone
OnboardingStep.View -> !viewDone OnboardingStep.View -> !viewDone
OnboardingStep.MonthStyle -> !monthStyleDone
} }
} }
return OnboardingPlan(steps, current) return OnboardingPlan(steps, current)

View File

@@ -1,5 +1,10 @@
package de.jeanlucmakiola.calendula.ui.onboarding 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,9 +25,6 @@ fun OnboardingSteps(
onPermissionGranted: () -> Unit, onPermissionGranted: () -> Unit,
modifier: Modifier = Modifier, 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 // 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 // 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. // 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) { when (step) {
OnboardingStep.Permission -> PermissionScreen( OnboardingStep.Permission -> PermissionScreen(
onGranted = onPermissionGranted, onGranted = onPermissionGranted,
@@ -49,25 +67,40 @@ fun OnboardingSteps(
onFinished = viewModel::finishReminders, onFinished = viewModel::finishReminders,
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
navigationIcon = navigationIcon,
) )
OnboardingStep.Backup -> BackupStep( OnboardingStep.Backup -> BackupStep(
onEnable = viewModel::enableAutoBackup, onEnable = viewModel::enableAutoBackup,
onSkip = viewModel::skipBackup, onSkip = viewModel::skipBackup,
modifier = modifier, modifier = modifier,
progress = progress, 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 -> { 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() val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
choice?.let { choice?.let {
ViewStep( ViewStep(
choice = it, choice = it,
onSelectView = viewModel::setDefaultView, onSelect = viewModel::setDefaultView,
onSelectMonthStyle = viewModel::setMonthViewStyle,
onFinished = viewModel::finishView, onFinished = viewModel::finishView,
modifier = modifier, modifier = modifier,
progress = progress, 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,
) )
} }
} }

View File

@@ -74,6 +74,7 @@ class OnboardingViewModel @Inject constructor(
prefs.onboardingWizardArmed, prefs.onboardingWizardArmed,
prefs.onboardingBackupDone, prefs.onboardingBackupDone,
prefs.onboardingViewDone, prefs.onboardingViewDone,
prefs.onboardingMonthStyleDone,
::OnboardingFlags, ::OnboardingFlags,
) )
@@ -86,6 +87,7 @@ class OnboardingViewModel @Inject constructor(
wizardArmed = stored.wizardArmed, wizardArmed = stored.wizardArmed,
backupDone = stored.backupDone, backupDone = stored.backupDone,
viewDone = stored.viewDone, viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone,
backupApplies = backup, 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. */ /** Close the backup step without setting anything up. */
fun skipBackup() { fun skipBackup() {
viewModelScope.launch { prefs.setOnboardingBackupDone() } viewModelScope.launch { prefs.setOnboardingBackupDone() }
@@ -172,10 +192,14 @@ class OnboardingViewModel @Inject constructor(
viewModelScope.launch { prefs.setMonthViewStyle(style) } 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() { fun finishView() {
viewModelScope.launch { prefs.setOnboardingViewDone() } viewModelScope.launch { prefs.setOnboardingViewDone() }
} }
fun finishMonthStyle() {
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
}
} }
/** The stored answers the plan is derived from. */ /** The stored answers the plan is derived from. */
@@ -184,6 +208,7 @@ private data class OnboardingFlags(
val wizardArmed: Boolean, val wizardArmed: Boolean,
val backupDone: Boolean, val backupDone: Boolean,
val viewDone: Boolean, val viewDone: Boolean,
val monthStyleDone: Boolean,
) )
/** What the view step starts from, and renders its previews with. */ /** What the view step starts from, and renders its previews with. */

View File

@@ -40,6 +40,7 @@ fun ReminderStep(
onFinished: (remindersEnabled: Boolean) -> Unit, onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null, progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) { ) {
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
@@ -48,6 +49,7 @@ fun ReminderStep(
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.NotificationsActive) }, hero = { IconHero(Icons.Filled.NotificationsActive) },
actions = { actions = {

View File

@@ -4,16 +4,14 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource 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 androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS 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.common.labelRes
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview 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.descriptionRes
import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace 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.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** /**
* Last wizard step (#163): which view the app opens on, chosen by looking at it * Wizard step: which view the app opens on (#163).
* rather than by reading four names and guessing.
* *
* One preview canvas serves both choices — when Month is the pick, a second row * Built like the Settings pickers it mirrors — a live preview over connected
* of chips for the month style appears under it and the same canvas re-renders. * grouped rows — so the choice is made by looking rather than by reading four
* Showing the month style on its own second preview would just be the picture * names and guessing, and so the same choice looks the same wherever it is
* above it twice. * made. Selecting applies immediately and leaves the step open; the preview
* * above is the confirmation.
* 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.
*/ */
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun ViewStep( internal fun ViewStep(
choice: ViewChoice, choice: ViewChoice,
onSelectView: (CalendarView) -> Unit, onSelect: (CalendarView) -> Unit,
onSelectMonthStyle: (MonthViewStyle) -> Unit,
onFinished: () -> Unit, onFinished: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null, progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) { ) {
val reduceMotion = rememberReduceMotion()
val weekStart = choice.weekStart.resolveFirstDay(currentLocale()) 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( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
topSpacing = OnboardingSpace.sm, navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.xs,
contentPadding = 0.dp,
hero = { hero = {
Text( Text(
text = stringResource(R.string.onboarding_view_title), text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center, modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset),
) )
}, },
actions = { 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 <T> 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( Crossfade(
modifier = Modifier.fillMaxWidth().height(PREVIEW_HEIGHT), targetState = selected,
contentAlignment = Alignment.Center, animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
) { label = "step-preview",
Crossfade( ) { shown ->
targetState = choice.defaultView to choice.monthViewStyle, // Each preview renders the real view at phone size and scales it
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), // down, so its own corners are square — the frame rounds it.
label = "view-preview", Box(
) { (view, style) -> modifier = Modifier
// Each preview renders the real view at phone size and scales it .clip(PREVIEW_SHAPE)
// down, so its own corners are square — the frame rounds it. .background(MaterialTheme.colorScheme.surface)
Box( .clipToBounds(),
modifier = Modifier ) {
.clip(PREVIEW_SHAPE) content(shown)
.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)
}
}
} }
} }
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) * Smaller than the Settings chooser's preview: this screen also carries the step
@Composable * counter and the Continue button, and the whole step has to fit without
private fun <T> ChipRow( * scrolling.
options: List<T>, */
selected: T, private val PREVIEW_HEIGHT: Dp = 200.dp
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
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -282,8 +282,10 @@
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string> <string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
<string name="onboarding_backup_enable_button">Choose folder and back up</string> <string name="onboarding_backup_enable_button">Choose folder and back up</string>
<string name="onboarding_backup_skip_button">Not now</string> <string name="onboarding_backup_skip_button">Not now</string>
<string name="onboarding_view_title">What should Calendula open on?</string> <string name="onboarding_view_title">What should open first?</string>
<string name="onboarding_month_style_title">How should Month look?</string>
<string name="onboarding_view_continue_button">Continue</string> <string name="onboarding_view_continue_button">Continue</string>
<string name="onboarding_back">Back</string>
<!-- Reminder notifications (v1.4) --> <!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string> <string name="reminder_channel_name">Event reminders</string>

View File

@@ -16,6 +16,7 @@ class OnboardingPlanTest {
wizardArmed: Boolean = false, wizardArmed: Boolean = false,
backupDone: Boolean = false, backupDone: Boolean = false,
viewDone: Boolean = false, viewDone: Boolean = false,
monthStyleDone: Boolean = false,
backupApplies: Boolean? = null, backupApplies: Boolean? = null,
) = onboardingPlan( ) = onboardingPlan(
hasPermission = hasPermission, hasPermission = hasPermission,
@@ -23,6 +24,7 @@ class OnboardingPlanTest {
wizardArmed = wizardArmed, wizardArmed = wizardArmed,
backupDone = backupDone, backupDone = backupDone,
viewDone = viewDone, viewDone = viewDone,
monthStyleDone = monthStyleDone,
backupApplies = backupApplies, backupApplies = backupApplies,
) )
@@ -44,6 +46,7 @@ class OnboardingPlanTest {
OnboardingStep.Reminders, OnboardingStep.Reminders,
OnboardingStep.Backup, OnboardingStep.Backup,
OnboardingStep.View, OnboardingStep.View,
OnboardingStep.MonthStyle,
).inOrder() ).inOrder()
assertThat(granted.current).isEqualTo(OnboardingStep.Reminders) assertThat(granted.current).isEqualTo(OnboardingStep.Reminders)
assertThat(granted.index).isEqualTo(2) assertThat(granted.index).isEqualTo(2)
@@ -59,7 +62,7 @@ class OnboardingPlanTest {
) )
assertThat(synced.steps).doesNotContain(OnboardingStep.Backup) assertThat(synced.steps).doesNotContain(OnboardingStep.Backup)
assertThat(synced.current).isEqualTo(OnboardingStep.View) assertThat(synced.current).isEqualTo(OnboardingStep.View)
assertThat(synced.total).isEqualTo(3) assertThat(synced.total).isEqualTo(4)
} }
@Test @Test
@@ -101,9 +104,63 @@ class OnboardingPlanTest {
wizardArmed = true, wizardArmed = true,
backupDone = true, backupDone = true,
viewDone = true, viewDone = true,
monthStyleDone = true,
backupApplies = true, backupApplies = true,
) )
assertThat(done.current).isNull() assertThat(done.current).isNull()
assertThat(done.index).isEqualTo(0) 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()
}
} }

View File

@@ -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: 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 - 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 - The optional steps (backup, view) only exist for an install that went
through the grant in-app — recorded by `armOnboardingWizard()`, which through the grant in-app — recorded by `armOnboardingWizard()`, which
refuses to arm if the reminder step was already answered, so an existing refuses to arm if the reminder step was already answered, so an existing