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:
@@ -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<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
|
||||
* **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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ClosingFlags> = combine(
|
||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||
prefs.onboardingVisibilityDone,
|
||||
prefs.onboardingDoneShown,
|
||||
::ClosingFlags,
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,10 @@
|
||||
<string name="onboarding_month_style_title">How should Month look?</string>
|
||||
<string name="onboarding_view_continue_button">Continue</string>
|
||||
<string name="onboarding_back">Back</string>
|
||||
<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) -->
|
||||
<string name="reminder_channel_name">Event reminders</string>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user