Extend onboarding into a wizard (#163)
The first launch becomes a counted flow. Two new steps: a backup step, shown only when nothing you write to is synced anywhere, which picks a folder and turns automatic backup on in one action; and a view step that picks the default view and month style from live previews. onboardingPlan() derives the whole flow from the stored answers, keeping completed steps so the counter never renumbers. The optional steps only exist for an install that went through the grant in-app, so existing users see nothing new. Week, Day and Agenda gain the scaled-down live preview Month already had, sharing one viewport scaler and one set of stand-in events.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -554,6 +554,40 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install went through the calendar grant in-app, i.e. is a
|
||||
* fresh one that owes the wizard's optional steps (#163). Set at the grant
|
||||
* and only for an install that had not finished the reminder step, so an
|
||||
* existing user who revokes and re-grants the permission isn't re-onboarded.
|
||||
*/
|
||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun armOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingBackupDone() {
|
||||
store.edit { it[ONBOARDING_BACKUP_KEY] = true }
|
||||
}
|
||||
|
||||
/** Whether the wizard's view step has been answered (or skipped). */
|
||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingViewDone() {
|
||||
store.edit { it[ONBOARDING_VIEW_KEY] = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = no default reminder — the prior
|
||||
@@ -882,6 +916,9 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||
|
||||
@@ -24,9 +24,17 @@ 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.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
private sealed interface RootTarget {
|
||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||
data object Loading : RootTarget
|
||||
data object App : RootTarget
|
||||
data class Step(val step: OnboardingStep) : RootTarget
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -76,33 +84,42 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), armed by the reconciler.
|
||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||
// Runs however the permission was granted, including via Android's
|
||||
// app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(hasPermission) {
|
||||
onboarding.setHasPermission(hasPermission)
|
||||
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()
|
||||
// Runs on entry however the permission was granted, including via
|
||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) visibilityNotice.reconcile()
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
|
||||
val current = plan
|
||||
val target: RootTarget = when {
|
||||
current == null -> RootTarget.Loading
|
||||
current.current == null -> RootTarget.App
|
||||
else -> RootTarget.Step(current.current)
|
||||
}
|
||||
|
||||
if (target is RootTarget.App && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// a step eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = target, animationSpec = gateSpec, label = "onboardingGate") { shown ->
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
@@ -116,18 +133,18 @@ fun RootScreen(
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
// The plan the outgoing step was drawn with is gone by the time the
|
||||
// fade ends, so the live one carries it — its counter is what the
|
||||
// incoming step should already show.
|
||||
is RootTarget.Step -> current?.let { plan ->
|
||||
OnboardingSteps(
|
||||
step = shown.step,
|
||||
plan = plan,
|
||||
viewModel = onboarding,
|
||||
onPermissionGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ private fun AgendaRangeBanner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
internal fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda preview's stand-in window runs. */
|
||||
private const val PREVIEW_WINDOW_DAYS = 14
|
||||
|
||||
/**
|
||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||
* the day headers are real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
AgendaContent(
|
||||
state = state,
|
||||
// Stand-in events are all upcoming, so the past-event preference
|
||||
// cannot change what the preview shows — keep it at the default.
|
||||
pastDisplay = PastEventDisplay.SHOW,
|
||||
showToday = true,
|
||||
onRetry = {},
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
rangeIsOverride = false,
|
||||
rangeEnd = windowEnd,
|
||||
// The range bar belongs to the screen, not the list — the preview shows
|
||||
// the list only.
|
||||
showRangeBar = false,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Stand-in events for the view previews. A preview has no business querying the
|
||||
* provider for a thumbnail, so every chooser renders the same invented week.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the views actually draw.
|
||||
*/
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
||||
private class SampleEvents(private val zone: TimeZone) {
|
||||
private var id = 0L
|
||||
|
||||
fun timed(
|
||||
day: LocalDate,
|
||||
hour: Int,
|
||||
minute: Int = 0,
|
||||
lengthMinutes: Int,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val start = day.atTime(hour, minute).toInstant(zone)
|
||||
return build(
|
||||
start = start,
|
||||
end = start + lengthMinutes.minutes,
|
||||
isAllDay = false,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
}
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
|
||||
private fun build(
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val next = ++id
|
||||
return EventInstance(
|
||||
instanceId = next,
|
||||
eventId = next,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
||||
* multi-day bar bridging cells and a busy today for the split pane.
|
||||
*/
|
||||
internal fun sampleMonthEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
||||
* is centred on — gets the busy set, including one overlapping pair so the
|
||||
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
||||
* all-day bar is only added when there is more than one column to span.
|
||||
*/
|
||||
internal fun sampleTimelineEvents(
|
||||
days: List<LocalDate>,
|
||||
anchor: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
if (days.size > 1) {
|
||||
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
||||
} else {
|
||||
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
||||
}
|
||||
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
||||
// under 09:00–10:00 so the overlap resolves into two lanes.
|
||||
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
||||
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
||||
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
||||
// Skip every third day so the week doesn't read as a solid block.
|
||||
if (index % 3 == 2) return@forEachIndexed
|
||||
add(
|
||||
sample.timed(
|
||||
day = day,
|
||||
hour = 8 + (index * 3) % 9,
|
||||
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
||||
colorIndex = index,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
||||
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
||||
* a full day and the gaps between days.
|
||||
*/
|
||||
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
||||
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
||||
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
||||
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||
* height keeps them comparable — each shows the same slice of screen.
|
||||
*/
|
||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for the settings and onboarding choosers.
|
||||
*
|
||||
* Previews render the *real* view composables rather than a drawing of them, so
|
||||
* they cannot drift from the thing they depict: change a cell's shape or an
|
||||
* event block's colour and every preview follows. The trick is the [layout]
|
||||
* below — it ignores the incoming constraints, so the view lays itself out at a
|
||||
* plausible phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
*
|
||||
* `Modifier.requiredSize` would be the obvious way to force the larger
|
||||
* measurement, but it *centres* content that overflows the incoming constraints
|
||||
* — which pushes the view to a negative offset and leaves only its bottom-right
|
||||
* corner inside the clip.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// view's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
val lanes = allDay.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -366,7 +366,7 @@ private fun DayContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaySuccess(
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
||||
* real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DayViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
||||
val days = listOf(today)
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return DayUiState.Success(
|
||||
date = today,
|
||||
today = today,
|
||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timed = layoutDay(events, today, zone),
|
||||
)
|
||||
}
|
||||
@@ -1,54 +1,31 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
* chooser.
|
||||
*
|
||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||
* event bar's colour and every preview follows automatically. The trick is
|
||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||
* out at a full phone width and a plausible viewport height, and a
|
||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||
* and onboarding choosers.
|
||||
*
|
||||
* The month, today's position and the week start are all real; only the events
|
||||
* are stand-ins, since the settings screen has no business querying the
|
||||
* provider for a thumbnail.
|
||||
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
||||
* measured at phone size and shrunk into the card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStylePreview(
|
||||
@@ -57,50 +34,11 @@ internal fun MonthStylePreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// grid's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure the grid at a full phone viewport but report the scaled
|
||||
// size, so the node occupies exactly what it draws.
|
||||
//
|
||||
// Modifier.requiredSize would be the obvious way to force the larger
|
||||
// measurement, but it *centres* content that overflows the incoming
|
||||
// constraints — which pushed the grid to a negative offset and left
|
||||
// only its bottom-right corner inside the clip.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
@@ -121,11 +59,10 @@ internal fun MonthStylePreview(
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a
|
||||
// stream running past the viewport rather than one
|
||||
// beginning at its top edge.
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(today, weekStart) - 1,
|
||||
// Start a week above today's, so the preview shows a stream
|
||||
// running past the viewport rather than one beginning at its
|
||||
// top edge.
|
||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
@@ -151,14 +88,7 @@ internal fun MonthStylePreview(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport the preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||
* three styles comparable — each shows the same slice of screen.
|
||||
*/
|
||||
private val VIRTUAL_HEIGHT = 440.dp
|
||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||
|
||||
private class SampleMonth(
|
||||
@@ -170,9 +100,6 @@ private class SampleMonth(
|
||||
* A month's worth of stand-in events, laid out through the same
|
||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a token here would misrepresent what the grid actually renders.
|
||||
*/
|
||||
private fun sampleMonthState(
|
||||
today: LocalDate,
|
||||
@@ -181,7 +108,7 @@ private fun sampleMonthState(
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleEvents(first, today, zone)
|
||||
val events = sampleMonthEvents(first, today, zone)
|
||||
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||
val month = MonthUiState.Success(
|
||||
@@ -213,72 +140,3 @@ private fun sampleMonthState(
|
||||
)
|
||||
return SampleMonth(month, continuous)
|
||||
}
|
||||
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private fun sampleEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun next() = ++id
|
||||
|
||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
// Spread across the month so most weeks carry something, with one multi-day
|
||||
// bar to show a span bridging cells and a busy today for the split pane.
|
||||
return buildList {
|
||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(timed(today, 9, 1, 0))
|
||||
add(timed(today, 12, 1, 1))
|
||||
add(timed(today, 15, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.CloudOff
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Wizard step shown only when everything you write to is a device-only calendar
|
||||
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
||||
*
|
||||
* The primary action picks a folder and switches automatic backup on in one go
|
||||
* — the step changes something rather than pointing at Settings. Everything it
|
||||
* sets is a normal Backup setting, so it stays adjustable afterwards.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupStep(
|
||||
onEnable: (Uri) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
// Cancelling the picker leaves the step open: nothing was decided, and
|
||||
// "Not now" is right there for the user who meant to decline.
|
||||
uri?.let(onEnable)
|
||||
}
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.FolderOpen,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Restore,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_restore_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_restore_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -64,3 +65,25 @@ internal fun BrandHero(denied: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
||||
* for the wizard steps that stand for a feature rather than for the app itself.
|
||||
*/
|
||||
@Composable
|
||||
internal fun IconHero(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
||||
enum class OnboardingStep {
|
||||
/** Required: nothing works without the calendar grant. */
|
||||
Permission,
|
||||
|
||||
/** Whether Calendula delivers reminder notifications itself. */
|
||||
Reminders,
|
||||
|
||||
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
||||
Backup,
|
||||
|
||||
/** Default view + month style, chosen from live previews. */
|
||||
View,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which steps this install owes and where it has got to. [steps] is the whole
|
||||
* flow — completed steps included — so the counter doesn't renumber as steps
|
||||
* are finished; [current] is null once nothing is left and the app opens.
|
||||
*/
|
||||
data class OnboardingPlan(
|
||||
val steps: List<OnboardingStep>,
|
||||
val current: OnboardingStep?,
|
||||
) {
|
||||
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
||||
val index: Int get() = steps.indexOf(current) + 1
|
||||
|
||||
val total: Int get() = steps.size
|
||||
|
||||
/** A one-step flow is not a wizard: there is nothing to count down. */
|
||||
val showsProgress: Boolean get() = total > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the flow from what is stored.
|
||||
*
|
||||
* The optional steps are only for installs that went through the grant in-app —
|
||||
* an existing user sees nothing new. Before the grant that can only be guessed,
|
||||
* so an install that has not answered the reminder step either is treated as
|
||||
* fresh, which is the same condition [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
||||
* commits to storage a moment later.
|
||||
*
|
||||
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
||||
* whole permission step. The backup step is assumed to apply until proven
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* counter had not accounted for.
|
||||
*/
|
||||
fun onboardingPlan(
|
||||
hasPermission: Boolean,
|
||||
remindersDone: Boolean,
|
||||
wizardArmed: Boolean,
|
||||
backupDone: Boolean,
|
||||
viewDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
): OnboardingPlan {
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) add(OnboardingStep.View)
|
||||
}
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
}
|
||||
}
|
||||
return OnboardingPlan(steps, current)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
|
||||
/**
|
||||
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
||||
* through [viewModel], which then hands the host the next one.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingSteps(
|
||||
step: OnboardingStep,
|
||||
plan: OnboardingPlan,
|
||||
viewModel: OnboardingViewModel,
|
||||
onPermissionGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Positioned from the step being drawn, not from the plan's own current
|
||||
// step: mid-crossfade the outgoing screen would otherwise jump to the
|
||||
// incoming one's number.
|
||||
val index = plan.steps.indexOf(step) + 1
|
||||
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
OnboardingProgress(
|
||||
step = index,
|
||||
total = plan.total,
|
||||
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> PermissionScreen(
|
||||
onGranted = onPermissionGranted,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.Reminders -> ReminderStep(
|
||||
onFinished = viewModel::finishReminders,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.Backup -> BackupStep(
|
||||
onEnable = viewModel::enableAutoBackup,
|
||||
onSkip = viewModel::skipBackup,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.View -> {
|
||||
// Null only until DataStore's first emission; rendering nothing for
|
||||
// that frame beats a preview built on the wrong defaults.
|
||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||
choice?.let {
|
||||
ViewStep(
|
||||
choice = it,
|
||||
onSelectView = viewModel::setDefaultView,
|
||||
onSelectMonthStyle = viewModel::setMonthViewStyle,
|
||||
onFinished = viewModel::finishView,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the first-launch wizard (#163): decides which steps this install owes
|
||||
* (see [onboardingPlan]) and commits each answer.
|
||||
*
|
||||
* [plan] is null until the first stored emission, so the host renders nothing
|
||||
* for that frame rather than flashing a step the user has already answered.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val repository: CalendarRepository,
|
||||
private val scanner: ReminderScanner,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Null until the host reports it — assuming either way would flash a screen. */
|
||||
private val hasPermission = MutableStateFlow<Boolean?>(null)
|
||||
|
||||
/**
|
||||
* Whether the backup step applies: something of yours is worth exporting and
|
||||
* nothing you write to is synced anywhere. Null while the calendar list
|
||||
* cannot be read, which is every frame before the grant.
|
||||
*/
|
||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
repository.calendars()
|
||||
.catch { emit(emptyList()) }
|
||||
.map { calendars -> calendars.backupApplies() }
|
||||
.flowOn(io)
|
||||
}
|
||||
}
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
prefs.onboardingBackupDone,
|
||||
prefs.onboardingViewDone,
|
||||
::OnboardingFlags,
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(hasPermission, flags, backupApplies) { granted, stored, backup ->
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
remindersDone = stored.remindersDone,
|
||||
wizardArmed = stored.wizardArmed,
|
||||
backupDone = stored.backupDone,
|
||||
viewDone = stored.viewDone,
|
||||
backupApplies = backup,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** The current view preferences, for the view step's initial selection. */
|
||||
val viewChoice: StateFlow<ViewChoice?> = combine(
|
||||
prefs.defaultView,
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
::ViewChoice,
|
||||
).stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
fun setHasPermission(granted: Boolean) {
|
||||
hasPermission.value = granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the in-app grant. The launch scan ran before it and bailed out
|
||||
* without arming anything, so the first alarm would otherwise wait on the
|
||||
* daily worker.
|
||||
*/
|
||||
fun onPermissionGranted() {
|
||||
viewModelScope.launch { prefs.armOnboardingWizard() }
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
|
||||
/** Close the reminder step, recording whether notifications stay on. */
|
||||
fun finishReminders(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the backup step without setting anything up. */
|
||||
fun skipBackup() {
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn automatic backup on, writing to the folder the user just picked
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
* close the step.
|
||||
*/
|
||||
fun enableAutoBackup(folder: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
folder,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
prefs.setAutoBackupFolderUri(folder.toString())
|
||||
prefs.setAutoBackupEnabled(true)
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = true,
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = true,
|
||||
)
|
||||
// Immediate feedback: the first backup lands while onboarding runs.
|
||||
BackupScheduler.runNow(context)
|
||||
prefs.setOnboardingBackupDone()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||
}
|
||||
|
||||
/** Close the view step — the choices themselves are saved as they are made. */
|
||||
fun finishView() {
|
||||
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored answers the plan is derived from. */
|
||||
private data class OnboardingFlags(
|
||||
val remindersDone: Boolean,
|
||||
val wizardArmed: Boolean,
|
||||
val backupDone: Boolean,
|
||||
val viewDone: Boolean,
|
||||
)
|
||||
|
||||
/** What the view step starts from, and renders its previews with. */
|
||||
data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val weekStart: WeekStartPref,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this device holds events that live nowhere else: at least one local
|
||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||
* A read-only subscription is not a backup, so it does not count.
|
||||
*/
|
||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||
return exportable && !syncedTarget
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -52,6 +52,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
||||
fun PermissionScreen(
|
||||
onGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
viewModel: PermissionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -76,6 +77,7 @@ fun PermissionScreen(
|
||||
when (state) {
|
||||
is PermissionUiState.Rationale -> RationaleContent(
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Denied -> DeniedContent(
|
||||
@@ -83,6 +85,7 @@ fun PermissionScreen(
|
||||
viewModel.onRetry()
|
||||
launcher.launch(CALENDAR_PERMISSIONS)
|
||||
},
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Granted -> {
|
||||
@@ -94,10 +97,12 @@ fun PermissionScreen(
|
||||
@Composable
|
||||
private fun RationaleContent(
|
||||
onRequest: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
hero = { BrandHero(denied = false) },
|
||||
actions = {
|
||||
Button(
|
||||
@@ -164,11 +169,13 @@ private fun RationaleContent(
|
||||
@Composable
|
||||
private fun DeniedContent(
|
||||
onRetry: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
hero = { BrandHero(denied = true) },
|
||||
actions = {
|
||||
Button(
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
sealed interface PermissionUiState {
|
||||
data object Rationale : PermissionUiState
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -8,26 +8,19 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -35,7 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||
* Wizard step after the calendar grant (v1.4): explains that
|
||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||
* when a second calendar app has notifications on, and requests
|
||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||
@@ -45,9 +38,10 @@ import de.jeanlucmakiola.calendula.R
|
||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderOnboardingScreen(
|
||||
fun ReminderStep(
|
||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
@@ -55,7 +49,8 @@ fun ReminderOnboardingScreen(
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BellHero() },
|
||||
progress = progress,
|
||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -121,22 +116,3 @@ fun ReminderOnboardingScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||
@Composable
|
||||
private fun BellHero() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.NotificationsActive,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
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.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Last wizard step (#163): which view the app opens on, chosen by looking at it
|
||||
* rather than by reading four names and guessing.
|
||||
*
|
||||
* One preview canvas serves both choices — when Month is the pick, a second row
|
||||
* of chips for the month style appears under it and the same canvas re-renders.
|
||||
* Showing the month style on its own second preview would just be the picture
|
||||
* above it twice.
|
||||
*
|
||||
* Selections apply as they are made, so there is nothing to confirm: the single
|
||||
* action closes the step, and pressing it without touching anything is what
|
||||
* skipping means — the current defaults are pre-selected.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun ViewStep(
|
||||
choice: ViewChoice,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onSelectMonthStyle: (MonthViewStyle) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
topSpacing = OnboardingSpace.sm,
|
||||
hero = {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_view_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_view_continue_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = choice.defaultView to choice.monthViewStyle,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "view-preview",
|
||||
) { (view, style) ->
|
||||
// Each preview renders the real view at phone size and scales it
|
||||
// down, so its own corners are square — the frame rounds it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
when (view) {
|
||||
CalendarView.Month -> MonthStylePreview(
|
||||
style = style,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Week -> WeekViewPreview(
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
ChipRow(
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = choice.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
onSelect = onSelectView,
|
||||
)
|
||||
|
||||
// The month style only means anything once Month is the view being
|
||||
// opened, so it stays out of the way until then.
|
||||
if (choice.defaultView == CalendarView.Month) {
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
ChipRow(
|
||||
options = MonthViewStyle.entries,
|
||||
selected = choice.monthViewStyle,
|
||||
label = { stringResource(it.labelRes) },
|
||||
onSelect = onSelectMonthStyle,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(choice.monthViewStyle.descriptionRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of single-choice chips, wrapping onto a second line where it must. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
FilterChip(
|
||||
selected = option == selected,
|
||||
onClick = { onSelect(option) },
|
||||
label = { Text(label(option)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PREVIEW_HEIGHT = 260.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -1,53 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
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
|
||||
|
||||
/**
|
||||
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||
* step neither flashes for users who completed it nor gets skipped.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ReminderOnboardingViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val scanner: ReminderScanner,
|
||||
) : ViewModel() {
|
||||
|
||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||
.map { done -> done as Boolean? }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Close the step, recording whether reminder notifications stay on. */
|
||||
fun finish(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||
* before the grant and bails out without arming anything, so without this
|
||||
* the first alarm waits on the daily worker.
|
||||
*/
|
||||
fun rearmAfterGrant() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -402,7 +402,7 @@ private fun WeekContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSuccess(
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Week view for the onboarding view chooser. The week and
|
||||
* today's column are real; only the events are stand-ins.
|
||||
*
|
||||
* The timeline is scrolled to the working day rather than to midnight — the
|
||||
* live view centres on noon once it knows its own scroll range, which a preview
|
||||
* that is never interacted with would otherwise never do.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekViewPreview(
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample week state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleWeekState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): WeekUiState.Success {
|
||||
val start = today.startOfWeek(weekStart)
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -268,6 +268,21 @@
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- First-launch wizard (#163) -->
|
||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||
<string name="onboarding_backup_title">Your events live only here</string>
|
||||
<string name="onboarding_backup_body">Nothing on this phone syncs to an account, so losing it would lose your calendar. Calendula can write a backup for you.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">A folder you choose</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||
<string name="onboarding_backup_benefit_restore_title">Restore anytime</string>
|
||||
<string name="onboarding_backup_benefit_restore_body">Open a backup file to bring the events back — on this phone or a new one.</string>
|
||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||
<string name="onboarding_backup_skip_button">Not now</string>
|
||||
<string name="onboarding_view_title">What should Calendula open on?</string>
|
||||
<string name="onboarding_view_continue_button">Continue</string>
|
||||
|
||||
<!-- Reminder notifications (v1.4) -->
|
||||
<string name="reminder_channel_name">Event reminders</string>
|
||||
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The wizard's step plan (#163). Two invariants carry the whole feature: an
|
||||
* existing install must never be re-onboarded, and the counter must not
|
||||
* renumber under the user as steps are completed.
|
||||
*/
|
||||
class OnboardingPlanTest {
|
||||
|
||||
private fun plan(
|
||||
hasPermission: Boolean = false,
|
||||
remindersDone: Boolean = false,
|
||||
wizardArmed: Boolean = false,
|
||||
backupDone: Boolean = false,
|
||||
viewDone: Boolean = false,
|
||||
backupApplies: Boolean? = null,
|
||||
) = onboardingPlan(
|
||||
hasPermission = hasPermission,
|
||||
remindersDone = remindersDone,
|
||||
wizardArmed = wizardArmed,
|
||||
backupDone = backupDone,
|
||||
viewDone = viewDone,
|
||||
backupApplies = backupApplies,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fresh install starts on the permission step`() {
|
||||
val fresh = plan()
|
||||
assertThat(fresh.current).isEqualTo(OnboardingStep.Permission)
|
||||
assertThat(fresh.index).isEqualTo(1)
|
||||
assertThat(fresh.showsProgress).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `granting the permission does not renumber the steps behind it`() {
|
||||
// The plan keeps completed steps, so the reminder step stays step 2 of
|
||||
// the same flow rather than becoming step 1 of a shorter one.
|
||||
val granted = plan(hasPermission = true, wizardArmed = true, backupApplies = true)
|
||||
assertThat(granted.steps).containsExactly(
|
||||
OnboardingStep.Permission,
|
||||
OnboardingStep.Reminders,
|
||||
OnboardingStep.Backup,
|
||||
OnboardingStep.View,
|
||||
).inOrder()
|
||||
assertThat(granted.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(granted.index).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced calendar drops the backup step`() {
|
||||
val synced = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
remindersDone = true,
|
||||
backupApplies = false,
|
||||
)
|
||||
assertThat(synced.steps).doesNotContain(OnboardingStep.Backup)
|
||||
assertThat(synced.current).isEqualTo(OnboardingStep.View)
|
||||
assertThat(synced.total).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the backup step is assumed until the calendars can be read`() {
|
||||
// Before the grant the answer is unknowable, so the flow is planned at
|
||||
// its longest — it may shrink afterwards, never grow.
|
||||
assertThat(plan(backupApplies = null).steps).contains(OnboardingStep.Backup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install is not re-onboarded`() {
|
||||
val existing = plan(hasPermission = true, remindersDone = true)
|
||||
assertThat(existing.steps).isEmpty()
|
||||
assertThat(existing.current).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install owing only the reminder step gets no counter`() {
|
||||
val existing = plan(hasPermission = true)
|
||||
assertThat(existing.steps).containsExactly(OnboardingStep.Reminders)
|
||||
assertThat(existing.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(existing.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-granting the permission on an onboarded install skips the extra steps`() {
|
||||
// Revoked and granted again: the reminder step is already answered, so
|
||||
// this is not a fresh install and only the permission is owed.
|
||||
val revoked = plan(remindersDone = true)
|
||||
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
|
||||
assertThat(revoked.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `answering every step ends the flow`() {
|
||||
val done = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
wizardArmed = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
backupApplies = true,
|
||||
)
|
||||
assertThat(done.current).isNull()
|
||||
assertThat(done.index).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph UI ["ui/ — Compose screens + ViewModels"]
|
||||
Screens["Month / Week / Day\nDetail / Edit / Settings\nPermission + Reminder onboarding"]
|
||||
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"]
|
||||
end
|
||||
subgraph Data ["data/"]
|
||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||
@@ -78,8 +78,8 @@ flowchart TD
|
||||
## Navigation
|
||||
|
||||
There is no navigation library. `MainActivity` hosts `RootScreen`, which
|
||||
gates on the calendar permission and the one-time reminder onboarding, then
|
||||
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
|
||||
gates on the first-launch wizard (`ui/onboarding/`), then shows
|
||||
`CalendarHost`. `CalendarHost` holds the active view (month/week/day)
|
||||
plus overlay state for detail, edit, and settings — full-screen overlays
|
||||
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
||||
key stays alive through the slide-out so content never flashes empty.
|
||||
@@ -87,6 +87,24 @@ A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||
exactly like an event tap.
|
||||
|
||||
### First-launch wizard
|
||||
|
||||
`ui/onboarding/` is a plan, not a navigation graph: `onboardingPlan()` is a
|
||||
pure function of the stored answers that returns the whole flow *plus* the
|
||||
step it is on, and `RootScreen` renders that step. Two rules make it work:
|
||||
|
||||
- The plan keeps **completed** steps, so finishing one never renumbers the
|
||||
counter under the user.
|
||||
- The optional steps (backup, view) only exist for an install that went
|
||||
through the grant in-app — recorded by `armOnboardingWizard()`, which
|
||||
refuses to arm if the reminder step was already answered, so an existing
|
||||
user who revokes and re-grants the permission is not re-onboarded.
|
||||
|
||||
Whether the backup step applies (nothing writable is synced anywhere) cannot
|
||||
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.
|
||||
|
||||
## Recurring writes
|
||||
|
||||
The provider's invariants drive the design (learned the hard way, verified
|
||||
|
||||
Submodule floret-kit updated: ea860ed781...a0d6e458a9
Reference in New Issue
Block a user