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
}
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<Boolean> = 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<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_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")

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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,
)
}
}

View File

@@ -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. */

View File

@@ -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 = {

View File

@@ -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 <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(
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 <T> ChipRow(
options: List<T>,
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)

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_enable_button">Choose folder and back up</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_back">Back</string>
<!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string>

View File

@@ -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()
}
}

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:
- 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