diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreenTest.kt similarity index 95% rename from app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt rename to app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreenTest.kt index aefebf3..e646d6b 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreenTest.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index b5d0ecf..907559f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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 = 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 = 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") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 14d4525..1268526 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -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,58 +84,67 @@ fun RootScreen( 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 - // 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. val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec() - 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 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 }, + Crossfade(targetState = target, animationSpec = gateSpec, label = "onboardingGate") { shown -> + when (shown) { + RootTarget.Loading -> Unit + RootTarget.App -> 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, ) + // 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, + ) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index dd6f486..776e200 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -276,7 +276,7 @@ private fun AgendaRangeBanner( } @Composable -private fun AgendaContent( +internal fun AgendaContent( state: AgendaUiState, pastDisplay: PastEventDisplay, showToday: Boolean, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt new file mode 100644 index 0000000..e6249fe --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PreviewSamples.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PreviewSamples.kt new file mode 100644 index 0000000..703edb8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PreviewSamples.kt @@ -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 { + 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, + anchor: LocalDate, + zone: TimeZone, +): List { + 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 { + 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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt new file mode 100644 index 0000000..552c0ca --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt @@ -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() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index b6ccc56..0c9098b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt new file mode 100644 index 0000000..27eeda2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt @@ -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), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index e5a47a1..7bc6618 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -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,108 +34,61 @@ 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()) { - WeekdayHeader(weekStart = weekStart, showWeekNumbers = false) - when (style) { - MonthViewStyle.Paged -> MonthGrid( - state = sample.month, - showWeekNumbers = false, - onOpenDay = {}, - ) - 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), - ) - } - } + ScaledViewPreview(height = height, modifier = modifier) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = false) + when (style) { + MonthViewStyle.Paged -> MonthGrid( + state = sample.month, + showWeekNumbers = false, + onOpenDay = {}, + ) + 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 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 { - 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", -) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt new file mode 100644 index 0000000..77ba78d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/BrandHero.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt similarity index 76% rename from app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/BrandHero.kt rename to app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt index de52799..4e096bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/BrandHero.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BrandHero.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt new file mode 100644 index 0000000..64e78dc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlan.kt @@ -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, + 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt new file mode 100644 index 0000000..9fc925b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingSteps.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..8169a87 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -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(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 = hasPermission.flatMapLatest { granted -> + if (granted != true) { + flowOf(null) + } else { + repository.calendars() + .catch { emit(emptyList()) } + .map { calendars -> calendars.backupApplies() } + .flowOn(io) + } + } + + private val flags: Flow = combine( + prefs.reminderOnboardingDone, + prefs.onboardingWizardArmed, + prefs.onboardingBackupDone, + prefs.onboardingViewDone, + ::OnboardingFlags, + ) + + val plan: StateFlow = + 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 = 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.backupApplies(): Boolean { + val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged } + val syncedTarget = any { !it.isLocal && it.canModifyContents } + return exportable && !syncedTarget +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt similarity index 96% rename from app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt rename to app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt index da293e1..b103c5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionUiState.kt similarity index 77% rename from app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt rename to app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionUiState.kt index fedef1f..49e8cd1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionUiState.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.calendula.ui.permission +package de.jeanlucmakiola.calendula.ui.onboarding sealed interface PermissionUiState { data object Rationale : PermissionUiState diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionViewModel.kt similarity index 94% rename from app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt rename to app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionViewModel.kt index 750af2f..f5a5ac9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt similarity index 80% rename from app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt rename to app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt index 27f7e21..55f2548 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt @@ -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), - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt new file mode 100644 index 0000000..2498897 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -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 ChipRow( + options: List, + 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt deleted file mode 100644 index ee705ac..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt +++ /dev/null @@ -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 = 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() - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 8c79743..a025355 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt new file mode 100644 index 0000000..83b1400 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -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) }, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66c5fae..8e6c138 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -268,6 +268,21 @@ (No title) + + Step %1$d of %2$d + Your events live only here + Nothing on this phone syncs to an account, so losing it would lose your calendar. Calendula can write a backup for you. + A folder you choose + Backups are plain .ics files — put them somewhere that syncs, or on an SD card. + Once a day, by itself + Calendula exports your local calendars in the background. Change how often in Settings. + Restore anytime + Open a backup file to bring the events back — on this phone or a new one. + Choose folder and back up + Not now + What should Calendula open on? + Continue + Event reminders Notifications at the reminder times of your events diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt new file mode 100644 index 0000000..69b06cf --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingPlanTest.kt @@ -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) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5ba192b..fa9b021 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/floret-kit b/floret-kit index ea860ed..a0d6e45 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit ea860ed7811fce08e0202cbc3a75fed0e8c3d166 +Subproject commit a0d6e458a9dc49feced6b967ff6d4c27ae2a4169