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 }
|
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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
/** 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)
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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_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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user