Close the wizard with an all-set screen and fold in the visibility notice (#163, #75)

The flow now ends on a full screen saying so instead of dropping into the
calendar, and the notice about calendars the device is hiding is a step in
front of it rather than a dialog thrown over the app afterwards. That notice
is the one optional step an already-onboarded install can still be given, so
it is not gated on the wizard being armed; on its own it is a single screen
with no counter and no closing screen behind it.
This commit is contained in:
2026-08-08 22:22:05 +02:00
parent e8f7f122a0
commit 61c3d8be00
13 changed files with 329 additions and 93 deletions

View File

@@ -598,6 +598,28 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done } 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<Boolean> = 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<Boolean> = 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 * The default reminder lead times (minutes before start) prefilled on new
* **timed** events. The empty list = no default reminder — the prior * **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_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
internal val ONBOARDING_MONTH_STYLE_KEY = internal val ONBOARDING_MONTH_STYLE_KEY =
booleanPreferencesKey("onboarding_month_style_done") 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 = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars") booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")

View File

@@ -25,8 +25,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission 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.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
@@ -99,12 +97,10 @@ fun RootScreen(
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted() if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
} }
// One-time explainer for the switch to the device's own calendar visibility // Arms the one-time explainer for the switch to the device's own calendar
// (#75), armed by the reconciler. // visibility (#75), which the wizard then carries as a step of its own.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
LaunchedEffect(hasPermission) { LaunchedEffect(hasPermission) {
if (hasPermission) visibilityNotice.reconcile() if (hasPermission) onboarding.reconcileCalendarVisibility()
} }
val current = plan val current = plan
@@ -114,10 +110,6 @@ fun RootScreen(
else -> RootTarget.Step(current.current) 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 // 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 // after every step and the blank first frame before them, so finishing the
// wizard reads as one more move forward. // wizard reads as one more move forward.

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -23,14 +24,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R 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 app's adaptive launcher mark, reconstructed as a large branded squircle —
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies * 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). * 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 @Composable
internal fun BrandHero(denied: Boolean) { internal fun BrandHero(badge: HeroBadge? = null) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -44,21 +49,31 @@ internal fun BrandHero(denied: Boolean) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
if (denied) { if (badge != null) {
// A small lock badge sits over the corner to signal "blocked". 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( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp) .offset(x = 10.dp, y = 10.dp)
.size(44.dp) .size(44.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer), .background(container),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Lock, imageVector = when (badge) {
HeroBadge.Denied -> Icons.Filled.Lock
HeroBadge.Done -> Icons.Filled.Check
},
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer, tint = onContainer,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
} }

View File

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

View File

@@ -16,6 +16,12 @@ enum class OnboardingStep {
/** How the Month view lays itself out, chosen from a live preview. */ /** How the Month view lays itself out, chosen from a live preview. */
MonthStyle, 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 * 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 * appear and disappear as the view is picked would move the counter under the
* user on the step right before it. * 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( fun onboardingPlan(
hasPermission: Boolean, hasPermission: Boolean,
@@ -73,6 +86,9 @@ fun onboardingPlan(
viewDone: Boolean, viewDone: Boolean,
monthStyleDone: Boolean, monthStyleDone: Boolean,
backupApplies: Boolean?, backupApplies: Boolean?,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
): OnboardingPlan { ): OnboardingPlan {
val fresh = wizardArmed || (!hasPermission && !remindersDone) val fresh = wizardArmed || (!hasPermission && !remindersDone)
val steps = buildList { val steps = buildList {
@@ -83,6 +99,8 @@ fun onboardingPlan(
add(OnboardingStep.View) add(OnboardingStep.View)
add(OnboardingStep.MonthStyle) add(OnboardingStep.MonthStyle)
} }
if (visibilityArmed) add(OnboardingStep.Visibility)
if (fresh) add(OnboardingStep.Done)
} }
val current = steps.firstOrNull { step -> val current = steps.firstOrNull { step ->
when (step) { when (step) {
@@ -91,6 +109,8 @@ fun onboardingPlan(
OnboardingStep.Backup -> !backupDone OnboardingStep.Backup -> !backupDone
OnboardingStep.View -> !viewDone OnboardingStep.View -> !viewDone
OnboardingStep.MonthStyle -> !monthStyleDone OnboardingStep.MonthStyle -> !monthStyleDone
OnboardingStep.Visibility -> !visibilityDone
OnboardingStep.Done -> !doneShown
} }
} }
return OnboardingPlan(steps, current) return OnboardingPlan(steps, current)

View File

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

View File

@@ -9,7 +9,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository 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.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
@@ -45,8 +47,10 @@ import javax.inject.Inject
class OnboardingViewModel @Inject constructor( class OnboardingViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val prefs: SettingsPrefs, private val prefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val scanner: ReminderScanner, private val scanner: ReminderScanner,
private val visibilityReconciler: CalendarVisibilityReconciler,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -78,8 +82,16 @@ class OnboardingViewModel @Inject constructor(
::OnboardingFlags, ::OnboardingFlags,
) )
/** The closing steps' own flags, and whether the notice applies at all. */
private val closingFlags: Flow<ClosingFlags> = combine(
calendarPrefs.visibilityNoticePending.map { it == true },
prefs.onboardingVisibilityDone,
prefs.onboardingDoneShown,
::ClosingFlags,
)
val plan: StateFlow<OnboardingPlan?> = val plan: StateFlow<OnboardingPlan?> =
combine(hasPermission, flags, backupApplies) { granted, stored, backup -> combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
granted?.let { granted?.let {
onboardingPlan( onboardingPlan(
hasPermission = it, hasPermission = it,
@@ -89,6 +101,9 @@ class OnboardingViewModel @Inject constructor(
viewDone = stored.viewDone, viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone, monthStyleDone = stored.monthStyleDone,
backupApplies = backup, backupApplies = backup,
visibilityArmed = closing.visibilityArmed,
visibilityDone = closing.visibilityDone,
doneShown = closing.doneShown,
) )
} }
}.stateIn( }.stateIn(
@@ -147,6 +162,8 @@ class OnboardingViewModel @Inject constructor(
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false) OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
OnboardingStep.View -> prefs.setOnboardingViewDone(false) OnboardingStep.View -> prefs.setOnboardingViewDone(false)
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false) OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
OnboardingStep.Permission, null -> Unit OnboardingStep.Permission, null -> Unit
} }
} }
@@ -200,6 +217,26 @@ class OnboardingViewModel @Inject constructor(
fun finishMonthStyle() { fun finishMonthStyle() {
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } 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. */ /** The stored answers the plan is derived from. */
@@ -211,6 +248,13 @@ private data class OnboardingFlags(
val monthStyleDone: Boolean, 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. */ /** What the view step starts from, and renders its previews with. */
data class ViewChoice( data class ViewChoice(
val defaultView: CalendarView, val defaultView: CalendarView,

View File

@@ -98,7 +98,7 @@ private fun RationaleContent(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
topSpacing = OnboardingSpace.lg, topSpacing = OnboardingSpace.lg,
hero = { BrandHero(denied = false) }, hero = { BrandHero() },
actions = { actions = {
Button( Button(
onClick = onRequest, onClick = onRequest,
@@ -157,7 +157,7 @@ private fun DeniedContent(
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, progress = progress,
hero = { BrandHero(denied = true) }, hero = { BrandHero(HeroBadge.Denied) },
actions = { actions = {
Button( Button(
onClick = { onClick = {

View File

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

View File

@@ -286,6 +286,10 @@
<string name="onboarding_month_style_title">How should Month look?</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> <string name="onboarding_back">Back</string>
<string name="onboarding_visibility_button">Got it</string>
<string name="onboarding_done_title">You\'re all set</string>
<string name="onboarding_done_body">Your calendars are ready. Everything you just chose can be changed later in Settings.</string>
<string name="onboarding_done_button">Open my calendar</string>
<!-- Reminder notifications (v1.4) --> <!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string> <string name="reminder_channel_name">Event reminders</string>

View File

@@ -18,6 +18,9 @@ class OnboardingPlanTest {
viewDone: Boolean = false, viewDone: Boolean = false,
monthStyleDone: Boolean = false, monthStyleDone: Boolean = false,
backupApplies: Boolean? = null, backupApplies: Boolean? = null,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
) = onboardingPlan( ) = onboardingPlan(
hasPermission = hasPermission, hasPermission = hasPermission,
remindersDone = remindersDone, remindersDone = remindersDone,
@@ -26,6 +29,9 @@ class OnboardingPlanTest {
viewDone = viewDone, viewDone = viewDone,
monthStyleDone = monthStyleDone, monthStyleDone = monthStyleDone,
backupApplies = backupApplies, backupApplies = backupApplies,
visibilityArmed = visibilityArmed,
visibilityDone = visibilityDone,
doneShown = doneShown,
) )
@Test @Test
@@ -47,6 +53,7 @@ class OnboardingPlanTest {
OnboardingStep.Backup, OnboardingStep.Backup,
OnboardingStep.View, OnboardingStep.View,
OnboardingStep.MonthStyle, OnboardingStep.MonthStyle,
OnboardingStep.Done,
).inOrder() ).inOrder()
assertThat(granted.current).isEqualTo(OnboardingStep.Reminders) assertThat(granted.current).isEqualTo(OnboardingStep.Reminders)
assertThat(granted.index).isEqualTo(2) assertThat(granted.index).isEqualTo(2)
@@ -62,7 +69,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(4) assertThat(synced.total).isEqualTo(5)
} }
@Test @Test
@@ -106,6 +113,7 @@ class OnboardingPlanTest {
viewDone = true, viewDone = true,
monthStyleDone = true, monthStyleDone = true,
backupApplies = true, backupApplies = true,
doneShown = true,
) )
assertThat(done.current).isNull() assertThat(done.current).isNull()
assertThat(done.index).isEqualTo(0) assertThat(done.index).isEqualTo(0)
@@ -159,8 +167,62 @@ class OnboardingPlanTest {
viewDone = true, viewDone = true,
monthStyleDone = true, monthStyleDone = true,
backupApplies = true, backupApplies = true,
doneShown = true,
) )
assertThat(done.previous).isNull() assertThat(done.previous).isNull()
assertThat(done.canGoBack).isFalse() 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()
}
} }

View File

@@ -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 read: the flow may shrink after the grant, never sprout a step the counter
had not accounted for. 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 ## Recurring writes
The provider's invariants drive the design (learned the hard way, verified The provider's invariants drive the design (learned the hard way, verified