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