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:
2026-08-08 21:02:44 +02:00
parent 342af07e09
commit d277578e77
27 changed files with 1458 additions and 342 deletions

View File

@@ -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.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule

View File

@@ -554,6 +554,40 @@ class SettingsPrefs @Inject constructor(
store.edit { it[REMINDER_ONBOARDING_KEY] = true } 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 * 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
@@ -882,6 +916,9 @@ class SettingsPrefs @Inject constructor(
internal const val MAX_EVENT_DURATION = 1_440 internal const val MAX_EVENT_DURATION = 1_440
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") 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 = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars") booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")

View File

@@ -24,9 +24,17 @@ 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.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -76,58 +84,67 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) } onDispose { lifecycle.removeObserver(obs) }
} }
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()
LaunchedEffect(hasPermission) {
if (hasPermission) visibilityNotice.reconcile()
}
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)
}
// Cross-fade the one-time onboarding gates so granting permission / finishing // Cross-fade the one-time onboarding gates so granting permission / finishing
// onboarding eases into the next screen instead of snapping. A fade carries no // a step eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too. // spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>() val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted -> Crossfade(targetState = target, animationSpec = gateSpec, label = "onboardingGate") { shown ->
if (granted) { when (shown) {
// Second onboarding gate (v1.4, one-time): reminder notifications. RootTarget.Loading -> Unit
// Null until DataStore's first emission — render nothing for that RootTarget.App -> CalendarHost(
// 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 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()
}
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier, modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
// 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,
)
}
} }
} }
} }

View File

@@ -276,7 +276,7 @@ private fun AgendaRangeBanner(
} }
@Composable @Composable
private fun AgendaContent( internal fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay, pastDisplay: PastEventDisplay,
showToday: Boolean, showToday: Boolean,

View File

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

View File

@@ -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:3011:00 running
// under 09:0010: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))
}
}

View File

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

View File

@@ -131,7 +131,7 @@ private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.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). */ /** 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 if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1 val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
@@ -366,7 +366,7 @@ private fun DayContent(
} }
@Composable @Composable
private fun DaySuccess( internal fun DaySuccess(
state: DayUiState.Success, state: DayUiState.Success,
topSectionColor: Color, topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,

View File

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

View File

@@ -1,54 +1,31 @@
package de.jeanlucmakiola.calendula.ui.month 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 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.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.math.roundToInt
/** /**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings * A live, scaled-down Month view in a given [MonthViewStyle], for the settings
* chooser. * and onboarding choosers.
*
* 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.
* *
* The month, today's position and the week start are all real; only the events * 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 * are stand-ins. See [ScaledViewPreview] for how the real grid composables get
* provider for a thumbnail. * measured at phone size and shrunk into the card.
*/ */
@Composable @Composable
internal fun MonthStylePreview( internal fun MonthStylePreview(
@@ -57,108 +34,61 @@ internal fun MonthStylePreview(
height: Dp, height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / VIRTUAL_HEIGHT.value
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) } val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
Box( ScaledViewPreview(height = height, modifier = modifier) {
modifier = modifier WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
.clipToBounds() when (style) {
.background(MaterialTheme.colorScheme.surface) MonthViewStyle.Paged -> MonthGrid(
// A preview is a picture, not a control: swallow touches before the state = sample.month,
// grid's own clickables see them, and give screen readers one label showWeekNumbers = false,
// instead of six weeks of day cells. onOpenDay = {},
.pointerInput(Unit) { )
awaitPointerEventScope { MonthViewStyle.Continuous -> ContinuousMonthGrid(
while (true) { state = sample.continuous,
awaitPointerEvent(PointerEventPass.Initial).changes listState = rememberLazyListState(
.forEach { it.consume() } initialFirstVisibleItemIndex = itemIndexForMonth(
} monthIndexOf(YearMonth(today.year, today.month)),
} ),
} ),
.clearAndSetSemantics { } showWeekNumbers = false,
// Measure the grid at a full phone viewport but report the scaled onOpenDay = {},
// size, so the node occupies exactly what it draws. )
// MonthViewStyle.Dense -> DenseMonthGrid(
// Modifier.requiredSize would be the obvious way to force the larger state = sample.continuous,
// measurement, but it *centres* content that overflows the incoming listState = rememberLazyListState(
// constraints — which pushed the grid to a negative offset and left // Start a week above today's, so the preview shows a stream
// only its bottom-right corner inside the clip. // running past the viewport rather than one beginning at its
.layout { measurable, _ -> // top edge.
val fullWidth = screenWidth.roundToPx() initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
val fullHeight = VIRTUAL_HEIGHT.roundToPx() ),
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight)) showWeekNumbers = false,
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) { onOpenDay = {},
placeable.place(0, 0) )
} MonthViewStyle.Split -> {
} SplitMonthGrid(
.graphicsLayer { state = sample.month,
scaleX = scale selected = today,
scaleY = scale showWeekNumbers = false,
transformOrigin = TransformOrigin(0f, 0f) onSelectDay = {},
}, )
) { SplitDayPane(
Column(Modifier.fillMaxSize()) { date = today,
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false) today = today,
when (style) { events = sample.month.instancesByDay[today].orEmpty(),
MonthViewStyle.Paged -> MonthGrid( zone = zone,
state = sample.month, onOpenDay = {},
showWeekNumbers = false, onEventClick = {},
onOpenDay = {}, onCreateEvent = {},
) modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
MonthViewStyle.Continuous -> ContinuousMonthGrid( )
state = sample.continuous,
listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)),
),
),
showWeekNumbers = false,
onOpenDay = {},
)
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,
),
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Split -> {
SplitMonthGrid(
state = sample.month,
selected = today,
showWeekNumbers = false,
onSelectDay = {},
)
SplitDayPane(
date = today,
today = today,
events = sample.month.instancesByDay[today].orEmpty(),
zone = zone,
onOpenDay = {},
onEventClick = {},
onCreateEvent = {},
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
)
}
}
} }
}
} }
} }
/**
* 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 val SPLIT_PANE_HEIGHT = 170.dp
private class SampleMonth( private class SampleMonth(
@@ -170,9 +100,6 @@ private class SampleMonth(
* A month's worth of stand-in events, laid out through the same * A month's worth of stand-in events, laid out through the same
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
* exercises the real span, lane and overflow logic rather than approximating it. * 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( private fun sampleMonthState(
today: LocalDate, today: LocalDate,
@@ -181,7 +108,7 @@ private fun sampleMonthState(
): SampleMonth { ): SampleMonth {
val ym = YearMonth(today.year, today.month) val ym = YearMonth(today.year, today.month)
val first = LocalDate(ym.year, ym.month, 1) 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 weeks = layoutMonthWeeks(ym, weekStart, events, zone)
val month = MonthUiState.Success( val month = MonthUiState.Success(
@@ -213,72 +140,3 @@ private fun sampleMonthState(
) )
return SampleMonth(month, continuous) 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",
)

View File

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

View File

@@ -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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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),
)
}
}

View File

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

View File

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

View File

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

View File

@@ -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.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
@@ -52,6 +52,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
fun PermissionScreen( fun PermissionScreen(
onGranted: () -> Unit, onGranted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
viewModel: PermissionViewModel = hiltViewModel(), viewModel: PermissionViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -76,6 +77,7 @@ fun PermissionScreen(
when (state) { when (state) {
is PermissionUiState.Rationale -> RationaleContent( is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) }, onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
progress = progress,
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Denied -> DeniedContent( is PermissionUiState.Denied -> DeniedContent(
@@ -83,6 +85,7 @@ fun PermissionScreen(
viewModel.onRetry() viewModel.onRetry()
launcher.launch(CALENDAR_PERMISSIONS) launcher.launch(CALENDAR_PERMISSIONS)
}, },
progress = progress,
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Granted -> { is PermissionUiState.Granted -> {
@@ -94,10 +97,12 @@ fun PermissionScreen(
@Composable @Composable
private fun RationaleContent( private fun RationaleContent(
onRequest: () -> Unit, onRequest: () -> Unit,
progress: (@Composable () -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress,
hero = { BrandHero(denied = false) }, hero = { BrandHero(denied = false) },
actions = { actions = {
Button( Button(
@@ -164,11 +169,13 @@ private fun RationaleContent(
@Composable @Composable
private fun DeniedContent( private fun DeniedContent(
onRetry: () -> Unit, onRetry: () -> Unit,
progress: (@Composable () -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress,
hero = { BrandHero(denied = true) }, hero = { BrandHero(denied = true) },
actions = { actions = {
Button( Button(

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.permission package de.jeanlucmakiola.calendula.ui.onboarding
sealed interface PermissionUiState { sealed interface PermissionUiState {
data object Rationale : PermissionUiState data object Rationale : PermissionUiState

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.permission package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel

View File

@@ -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.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
@@ -8,26 +8,19 @@ import android.Manifest
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.Icons
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -35,7 +28,7 @@ import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.R 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 * Calendula delivers reminder notifications itself, warns about duplicates
* when a second calendar app has notifications on, and requests * when a second calendar app has notifications on, and requests
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29). * `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. * the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/ */
@Composable @Composable
fun ReminderOnboardingScreen( fun ReminderStep(
onFinished: (remindersEnabled: Boolean) -> Unit, onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
) { ) {
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
@@ -55,7 +49,8 @@ fun ReminderOnboardingScreen(
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
hero = { BellHero() }, progress = progress,
hero = { IconHero(Icons.Filled.NotificationsActive) },
actions = { actions = {
Button( Button(
onClick = { 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),
)
}
}

View File

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

View File

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

View File

@@ -148,7 +148,7 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
private val COLUMN_GAP = 2.dp private val COLUMN_GAP = 2.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */ /** 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 if (allDaySpans.isEmpty()) return 0.dp
val lanes = allDaySpans.maxOf { it.lane } + 1 val lanes = allDaySpans.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
@@ -402,7 +402,7 @@ private fun WeekContent(
} }
@Composable @Composable
private fun WeekSuccess( internal fun WeekSuccess(
state: WeekUiState.Success, state: WeekUiState.Success,
topSectionColor: Color, topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,

View File

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

View File

@@ -268,6 +268,21 @@
<!-- Shared event strings --> <!-- Shared event strings -->
<string name="event_untitled">(No title)</string> <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) --> <!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string> <string name="reminder_channel_name">Event reminders</string>
<string name="reminder_channel_description">Notifications at the reminder times of your events</string> <string name="reminder_channel_description">Notifications at the reminder times of your events</string>

View File

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

View File

@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
```mermaid ```mermaid
flowchart TD flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"] 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 end
subgraph Data ["data/"] subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
@@ -78,8 +78,8 @@ flowchart TD
## Navigation ## Navigation
There is no navigation library. `MainActivity` hosts `RootScreen`, which There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the calendar permission and the one-time reminder onboarding, then gates on the first-launch wizard (`ui/onboarding/`), then shows
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day) `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
plus overlay state for detail, edit, and settings — full-screen overlays plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty. 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 `onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap. 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 ## 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