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 a0ccc9f..16a2de3 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 @@ -598,6 +598,28 @@ class SettingsPrefs @Inject constructor( store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done } } + /** + * Whether the wizard's calendar-visibility notice has been read. Separate + * from [CalendarPrefs.visibilityNoticePending], which says whether this + * install has anything to announce at all. + */ + val onboardingVisibilityDone: Flow = store.data.map { prefs -> + prefs[ONBOARDING_VISIBILITY_KEY] ?: false + } + + suspend fun setOnboardingVisibilityDone(done: Boolean = true) { + store.edit { it[ONBOARDING_VISIBILITY_KEY] = done } + } + + /** Whether the wizard's closing screen has been seen. */ + val onboardingDoneShown: Flow = store.data.map { prefs -> + prefs[ONBOARDING_DONE_KEY] ?: false + } + + suspend fun setOnboardingDoneShown(shown: Boolean = true) { + store.edit { it[ONBOARDING_DONE_KEY] = shown } + } + /** * The default reminder lead times (minutes before start) prefilled on new * **timed** events. The empty list = no default reminder — the prior @@ -931,6 +953,9 @@ class SettingsPrefs @Inject constructor( internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done") internal val ONBOARDING_MONTH_STYLE_KEY = booleanPreferencesKey("onboarding_month_style_done") + internal val ONBOARDING_VISIBILITY_KEY = + booleanPreferencesKey("onboarding_visibility_done") + internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown") 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 6523d30..51fcb10 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -25,8 +25,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission -import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog -import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps @@ -99,12 +97,10 @@ fun RootScreen( if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted() } - // One-time explainer for the switch to the device's own calendar visibility - // (#75), armed by the reconciler. - val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel() - val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle() + // Arms the one-time explainer for the switch to the device's own calendar + // visibility (#75), which the wizard then carries as a step of its own. LaunchedEffect(hasPermission) { - if (hasPermission) visibilityNotice.reconcile() + if (hasPermission) onboarding.reconcileCalendarVisibility() } val current = plan @@ -114,10 +110,6 @@ fun RootScreen( else -> RootTarget.Step(current.current) } - if (target is RootTarget.App && noticePending) { - 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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarVisibilityNotice.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarVisibilityNotice.kt deleted file mode 100644 index 107005c..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarVisibilityNotice.kt +++ /dev/null @@ -1,71 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.calendars - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler -import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * The one-time notice that Calendula now follows the device's per-calendar - * visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not - * switch those calendars back on — that would un-hide them everywhere else too. - */ -@HiltViewModel -class CalendarVisibilityNoticeViewModel @Inject constructor( - private val prefs: CalendarPrefs, - private val reconciler: CalendarVisibilityReconciler, -) : ViewModel() { - - /** - * Reconcile whenever the app comes up with the calendar permission held, - * rather than off one grant route: a permission granted on Android's - * app-settings screen never reaches the permission screen's callback. - */ - fun reconcile() { - viewModelScope.launch { reconciler.run() } - } - - val pending: StateFlow = prefs.visibilityNoticePending - .map { it == true } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = false, - ) - - fun dismiss() { - viewModelScope.launch { prefs.setVisibilityNoticePending(false) } - } -} - -/** Plain informational dialog — one acknowledgement, nothing to decide. */ -@Composable -fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) }, - title = { Text(stringResource(R.string.calendars_visibility_notice_title)) }, - text = { Text(stringResource(R.string.calendars_visibility_notice_message)) }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_ok)) - } - }, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt index 4e096bb..8417c11 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -23,14 +24,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R +/** What, if anything, the brand mark is wearing in its corner. */ +internal enum class HeroBadge { Denied, Done } + /** * The app's adaptive launcher mark, reconstructed as a large branded squircle — * the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies * the shell; the mark stays app-local so each sibling keeps its own identity). - * A lock badge overlays the corner when permission has been [denied]. + * A [badge] overlays the corner where the screen has a verdict to give: blocked, + * or finished. */ @Composable -internal fun BrandHero(denied: Boolean) { +internal fun BrandHero(badge: HeroBadge? = null) { Box(contentAlignment = Alignment.Center) { Box( modifier = Modifier @@ -44,21 +49,31 @@ internal fun BrandHero(denied: Boolean) { modifier = Modifier.fillMaxSize(), ) } - if (denied) { - // A small lock badge sits over the corner to signal "blocked". + if (badge != null) { + val container = when (badge) { + HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer + HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer + } + val onContainer = when (badge) { + HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer + HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer + } Box( modifier = Modifier .align(Alignment.BottomEnd) .offset(x = 10.dp, y = 10.dp) .size(44.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.errorContainer), + .background(container), contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Filled.Lock, + imageVector = when (badge) { + HeroBadge.Denied -> Icons.Filled.Lock + HeroBadge.Done -> Icons.Filled.Check + }, contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, + tint = onContainer, modifier = Modifier.size(24.dp), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt new file mode 100644 index 0000000..59c5154 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * The wizard's closing screen (#163): the flow ends on a full screen saying so + * rather than dropping into the calendar mid-gesture, and the brand mark wears + * the check that the last step earned. + */ +@Composable +internal fun DoneStep( + onFinished: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.lg, + hero = { BrandHero(HeroBadge.Done) }, + actions = { + Button( + onClick = onFinished, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_done_button), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + Text( + text = stringResource(R.string.onboarding_done_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_done_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} 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 60c27f4..6fd893c 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 @@ -16,6 +16,12 @@ enum class OnboardingStep { /** How the Month view lays itself out, chosen from a live preview. */ MonthStyle, + + /** Only on an install that has calendars the device is hiding (#75). */ + Visibility, + + /** The closing screen: everything is answered and the app is ready. */ + Done, } /** @@ -64,6 +70,13 @@ data class OnboardingPlan( * 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. + * + * [visibilityArmed] carries the one-time notice that the device is hiding some + * calendars (#75). It is a step rather than a dialog over the app so that an + * install owing both meets it inside the flow instead of straight after it — + * and it is the only optional step an *existing* install can be given, which is + * why it is not gated on `fresh`. The closing screen is: there is nothing to + * conclude for someone who was only shown one notice. */ fun onboardingPlan( hasPermission: Boolean, @@ -73,6 +86,9 @@ fun onboardingPlan( viewDone: Boolean, monthStyleDone: Boolean, backupApplies: Boolean?, + visibilityArmed: Boolean = false, + visibilityDone: Boolean = false, + doneShown: Boolean = false, ): OnboardingPlan { val fresh = wizardArmed || (!hasPermission && !remindersDone) val steps = buildList { @@ -83,6 +99,8 @@ fun onboardingPlan( add(OnboardingStep.View) add(OnboardingStep.MonthStyle) } + if (visibilityArmed) add(OnboardingStep.Visibility) + if (fresh) add(OnboardingStep.Done) } val current = steps.firstOrNull { step -> when (step) { @@ -91,6 +109,8 @@ fun onboardingPlan( OnboardingStep.Backup -> !backupDone OnboardingStep.View -> !viewDone OnboardingStep.MonthStyle -> !monthStyleDone + OnboardingStep.Visibility -> !visibilityDone + OnboardingStep.Done -> !doneShown } } 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 68e76ed..0ec1e2b 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 @@ -104,5 +104,17 @@ fun OnboardingSteps( ) } } + OnboardingStep.Visibility -> VisibilityStep( + onFinished = viewModel::finishVisibility, + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + ) + OnboardingStep.Done -> DoneStep( + onFinished = viewModel::finishOnboarding, + 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 9de1d1a..1c9f531 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 @@ -9,7 +9,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner @@ -45,8 +47,10 @@ import javax.inject.Inject class OnboardingViewModel @Inject constructor( @ApplicationContext private val context: Context, private val prefs: SettingsPrefs, + private val calendarPrefs: CalendarPrefs, private val repository: CalendarRepository, private val scanner: ReminderScanner, + private val visibilityReconciler: CalendarVisibilityReconciler, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -78,8 +82,16 @@ class OnboardingViewModel @Inject constructor( ::OnboardingFlags, ) + /** The closing steps' own flags, and whether the notice applies at all. */ + private val closingFlags: Flow = combine( + calendarPrefs.visibilityNoticePending.map { it == true }, + prefs.onboardingVisibilityDone, + prefs.onboardingDoneShown, + ::ClosingFlags, + ) + val plan: StateFlow = - combine(hasPermission, flags, backupApplies) { granted, stored, backup -> + combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup -> granted?.let { onboardingPlan( hasPermission = it, @@ -89,6 +101,9 @@ class OnboardingViewModel @Inject constructor( viewDone = stored.viewDone, monthStyleDone = stored.monthStyleDone, backupApplies = backup, + visibilityArmed = closing.visibilityArmed, + visibilityDone = closing.visibilityDone, + doneShown = closing.doneShown, ) } }.stateIn( @@ -147,6 +162,8 @@ class OnboardingViewModel @Inject constructor( OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false) OnboardingStep.View -> prefs.setOnboardingViewDone(false) OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false) + OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false) + OnboardingStep.Done -> prefs.setOnboardingDoneShown(false) OnboardingStep.Permission, null -> Unit } } @@ -200,6 +217,26 @@ class OnboardingViewModel @Inject constructor( fun finishMonthStyle() { viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } } + + /** Acknowledge the calendar-visibility notice (#75). */ + fun finishVisibility() { + viewModelScope.launch { prefs.setOnboardingVisibilityDone() } + } + + /** Close the wizard from its last screen. */ + fun finishOnboarding() { + viewModelScope.launch { prefs.setOnboardingDoneShown() } + } + + /** + * Fold the retired app-local calendar visibility into the device's own, and + * arm the notice if this install has anything to announce. Runs whenever the + * app comes up holding the permission, however it was granted — one taken on + * Android's app-settings screen never reaches the permission step's callback. + */ + fun reconcileCalendarVisibility() { + viewModelScope.launch { visibilityReconciler.run() } + } } /** The stored answers the plan is derived from. */ @@ -211,6 +248,13 @@ private data class OnboardingFlags( val monthStyleDone: Boolean, ) +/** The tail of the flow: the visibility notice and the closing screen. */ +private data class ClosingFlags( + val visibilityArmed: Boolean, + val visibilityDone: Boolean, + val doneShown: Boolean, +) + /** What the view step starts from, and renders its previews with. */ data class ViewChoice( val defaultView: CalendarView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt index 1be8f7f..da7d022 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt @@ -98,7 +98,7 @@ private fun RationaleContent( modifier = modifier, progress = progress, topSpacing = OnboardingSpace.lg, - hero = { BrandHero(denied = false) }, + hero = { BrandHero() }, actions = { Button( onClick = onRequest, @@ -157,7 +157,7 @@ private fun DeniedContent( OnboardingScaffold( modifier = modifier, progress = progress, - hero = { BrandHero(denied = true) }, + hero = { BrandHero(HeroBadge.Denied) }, actions = { Button( onClick = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt new file mode 100644 index 0000000..b6cf7ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * Wizard step for the one-time notice that Calendula now follows the device's + * own per-calendar visibility and some of yours are switched off (#75, #163). + * + * It used to be a dialog thrown over the app once onboarding had finished, which + * put the one thing that explains a changed behaviour outside the flow that + * explains everything else. Nothing to decide here — the app does not switch + * those calendars back on, since that would un-hide them everywhere else too. + */ +@Composable +internal fun VisibilityStep( + onFinished: () -> Unit, + modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, +) { + OnboardingScaffold( + modifier = modifier, + progress = progress, + navigationIcon = navigationIcon, + topSpacing = OnboardingSpace.lg, + hero = { IconHero(Icons.Filled.VisibilityOff) }, + actions = { + Button( + onClick = onFinished, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_visibility_button), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + Text( + text = stringResource(R.string.calendars_visibility_notice_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.calendars_visibility_notice_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 559831e..c855f2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,10 @@ How should Month look? Continue Back + Got it + You\'re all set + Your calendars are ready. Everything you just chose can be changed later in Settings. + Open my calendar 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 411e433..d6475c2 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 @@ -18,6 +18,9 @@ class OnboardingPlanTest { viewDone: Boolean = false, monthStyleDone: Boolean = false, backupApplies: Boolean? = null, + visibilityArmed: Boolean = false, + visibilityDone: Boolean = false, + doneShown: Boolean = false, ) = onboardingPlan( hasPermission = hasPermission, remindersDone = remindersDone, @@ -26,6 +29,9 @@ class OnboardingPlanTest { viewDone = viewDone, monthStyleDone = monthStyleDone, backupApplies = backupApplies, + visibilityArmed = visibilityArmed, + visibilityDone = visibilityDone, + doneShown = doneShown, ) @Test @@ -47,6 +53,7 @@ class OnboardingPlanTest { OnboardingStep.Backup, OnboardingStep.View, OnboardingStep.MonthStyle, + OnboardingStep.Done, ).inOrder() assertThat(granted.current).isEqualTo(OnboardingStep.Reminders) assertThat(granted.index).isEqualTo(2) @@ -62,7 +69,7 @@ class OnboardingPlanTest { ) assertThat(synced.steps).doesNotContain(OnboardingStep.Backup) assertThat(synced.current).isEqualTo(OnboardingStep.View) - assertThat(synced.total).isEqualTo(4) + assertThat(synced.total).isEqualTo(5) } @Test @@ -106,6 +113,7 @@ class OnboardingPlanTest { viewDone = true, monthStyleDone = true, backupApplies = true, + doneShown = true, ) assertThat(done.current).isNull() assertThat(done.index).isEqualTo(0) @@ -159,8 +167,62 @@ class OnboardingPlanTest { viewDone = true, monthStyleDone = true, backupApplies = true, + doneShown = true, ) assertThat(done.previous).isNull() assertThat(done.canGoBack).isFalse() } + + @Test + fun `the wizard ends on its closing screen`() { + val lastAnswer = plan( + hasPermission = true, + remindersDone = true, + wizardArmed = true, + backupDone = true, + viewDone = true, + monthStyleDone = true, + backupApplies = true, + ) + assertThat(lastAnswer.current).isEqualTo(OnboardingStep.Done) + assertThat(lastAnswer.index).isEqualTo(lastAnswer.total) + } + + @Test + fun `the visibility notice is asked before the closing screen`() { + val armed = plan( + hasPermission = true, + remindersDone = true, + wizardArmed = true, + backupDone = true, + viewDone = true, + monthStyleDone = true, + backupApplies = true, + visibilityArmed = true, + ) + assertThat(armed.current).isEqualTo(OnboardingStep.Visibility) + assertThat(armed.steps.last()).isEqualTo(OnboardingStep.Done) + } + + @Test + fun `an onboarded install owing only the notice gets one screen and no counter`() { + // The notice is the one step an existing install can still be given — + // and on its own it is not a wizard, so there is nothing to conclude. + val notice = plan(hasPermission = true, remindersDone = true, visibilityArmed = true) + assertThat(notice.steps).containsExactly(OnboardingStep.Visibility) + assertThat(notice.current).isEqualTo(OnboardingStep.Visibility) + assertThat(notice.showsProgress).isFalse() + assertThat(notice.canGoBack).isFalse() + } + + @Test + fun `reading the notice ends an otherwise onboarded flow`() { + val read = plan( + hasPermission = true, + remindersDone = true, + visibilityArmed = true, + visibilityDone = true, + ) + assertThat(read.current).isNull() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6e09e19..7b6e17a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,6 +107,11 @@ be known before the grant, so it is assumed until the calendar list can be read: the flow may shrink after the grant, never sprout a step the counter had not accounted for. +The flow closes on an "all set" screen, and the one-time calendar-visibility +notice (#75) is a step in front of it rather than a dialog over the app — it +is the only optional step an *already onboarded* install can be given, which +is why it alone is not gated on the wizard being armed. + ## Recurring writes The provider's invariants drive the design (learned the hard way, verified