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:
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Submodule floret-kit updated: a0d6e458a9...80ed899b55
Reference in New Issue
Block a user