diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 2702a6c..cd37a84 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -108,10 +108,16 @@ class SettingsPrefs @Inject constructor( suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } - /** One-time reminder onboarding gate; false until the step has been shown. */ - val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false } + /** + * One-time first-run gate; false until the flow has been walked through. + * + * The key still says `reminder_onboarding_done` — it gated a single reminder + * step before the flow grew around it, and renaming it would drag every + * existing install back through onboarding. + */ + val onboardingDone: Flow = dataStore.data.map { it[ONBOARDING_DONE] ?: false } - suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true } + suspend fun setOnboardingDone() = dataStore.edit { it[ONBOARDING_DONE] = true } suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled } @@ -138,7 +144,7 @@ class SettingsPrefs @Inject constructor( val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled") val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar") - val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") + val ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") val STORAGE_MODE = stringPreferencesKey("storage_mode") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index 3154095..e9055a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -3,19 +3,22 @@ package de.jeanlucmakiola.agendula.ui import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement -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.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Lock 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.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,9 +26,12 @@ import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost +import de.jeanlucmakiola.agendula.ui.onboarding.OnboardingFlow +import de.jeanlucmakiola.agendula.ui.onboarding.OnboardingViewModel +import de.jeanlucmakiola.agendula.ui.onboarding.SquircleHero import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel -import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen -import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace /** * App root: gates on the tasks-provider permission, then hands off to @@ -54,13 +60,15 @@ fun RootScreen( when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, + icon = Icons.Rounded.CloudOff, title = stringResource(R.string.onboarding_no_provider_title), body = stringResource(R.string.onboarding_no_provider_body), - secondaryAction = fallback, - onSecondaryAction = permissionViewModel::useOwnStore, + action = fallback, + onAction = permissionViewModel::useOwnStore, ) ProviderStatus.NEEDS_PERMISSION -> Gate( modifier = modifier, + icon = Icons.Rounded.Lock, title = stringResource(R.string.onboarding_permission_title), body = stringResource(R.string.onboarding_permission_body), action = stringResource(R.string.onboarding_permission_button), @@ -73,47 +81,76 @@ fun RootScreen( } /** - * Second one-time gate after the provider grant: the reminder onboarding step. - * [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first - * emission — render nothing for that frame rather than flash the wrong screen. - * A cross-fade eases the hand-off to the app instead of snapping. + * Second one-time gate after the provider grant: the first-run flow. + * [OnboardingViewModel.done] is null until DataStore's first emission — render + * nothing for that frame rather than flash the wrong screen. A cross-fade eases + * the hand-off to the app instead of snapping. */ @Composable private fun ReadyGate( modifier: Modifier = Modifier, - onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(), + onboardingViewModel: OnboardingViewModel = hiltViewModel(), ) { - val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle() - Crossfade(targetState = done, label = "reminderOnboardingGate") { state -> + val done by onboardingViewModel.done.collectAsStateWithLifecycle() + Crossfade(targetState = done, label = "onboardingGate") { state -> when (state) { true -> AgendulaNavHost(modifier = modifier) - false -> ReminderOnboardingScreen( - onFinished = onboardingViewModel::finish, - modifier = modifier, - ) + false -> OnboardingFlow(modifier = modifier, viewModel = onboardingViewModel) null -> {} } } } +/** + * A dead end the External store can put the app in: no provider installed, or + * its permission refused. Built on the same shell as onboarding — it is the + * first and only screen a user in this state sees, and a bare column reads as a + * crash rather than a choice. + * + * Only reachable by having chosen External in Settings, so the way out — + * Agendula's own store — is always among the actions, and is the primary one + * where there is nothing else to try. + */ @Composable private fun Gate( + icon: ImageVector, title: String, body: String, + action: String, + onAction: () -> Unit, modifier: Modifier = Modifier, - action: String? = null, - onAction: () -> Unit = {}, secondaryAction: String? = null, onSecondaryAction: () -> Unit = {}, ) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + OnboardingScaffold( + modifier = modifier, + hero = { SquircleHero(icon) }, + topSpacing = OnboardingSpace.xl, + actions = { + Button( + onClick = onAction, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text(action, style = MaterialTheme.typography.titleMedium) + } + if (secondaryAction != null) { + TextButton(onClick = onSecondaryAction, modifier = Modifier.fillMaxWidth()) { + Text(secondaryAction) + } + } + }, ) { - Text(title, style = MaterialTheme.typography.headlineSmall) - Text(body, style = MaterialTheme.typography.bodyMedium) - if (action != null) Button(onClick = onAction) { Text(action) } - if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) } + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt index c88d746..fe847f3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt @@ -67,11 +67,18 @@ import de.jeanlucmakiola.floret.components.positionOf * independently reachable — you cannot pick lists before signing in, and going * "back" from the browser step means abandoning a server-side flow rather than * popping a screen. + * + * [stepOffset] and [totalSteps] let a longer flow host this one inline — first + * run offers the account before it has finished onboarding — so the wizard's + * three steps report their place in the *outer* progress rather than restarting + * the count at one. Left at their defaults it stands alone. */ @Composable internal fun AddAccountScreen( onDone: () -> Unit, onBack: () -> Unit, + stepOffset: Int = 0, + totalSteps: Int = ADD_ACCOUNT_STEPS, viewModel: AddAccountViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -123,16 +130,12 @@ internal fun AddAccountScreen( contentPadding = 0.dp, topSpacing = OnboardingSpace.md, progress = { - val position = state.step.position + val position = state.step.position?.plus(stepOffset) if (position != null) { OnboardingProgress( step = position, - total = ADD_ACCOUNT_STEPS, - label = stringResource( - R.string.add_account_step_of, - position, - ADD_ACCOUNT_STEPS, - ), + total = totalSteps, + label = stringResource(R.string.add_account_step_of, position, totalSteps), ) } }, @@ -195,7 +198,8 @@ private val AddAccountStep.position: Int? is AddAccountStep.Working, AddAccountStep.Done -> null } -private const val ADD_ACCOUNT_STEPS = 3 +/** The wizard's own visible steps, and the default denominator of its progress. */ +internal const val ADD_ACCOUNT_STEPS = 3 /** The mark for the step, in the family's tonal circle. */ @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt index 7ce564a..d93f4c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -96,7 +96,7 @@ fun ListEditorSheet( ) { Text(stringResource(R.string.save)) } }, ) { - NameField( + ListNameField( name = name, color = color, // A new list opens with the keyboard up: naming it is the whole task. @@ -107,7 +107,7 @@ fun ListEditorSheet( Spacer(Modifier.height(20.dp)) SectionLabel(stringResource(R.string.list_color)) - ColorGrid(selected = color, onSelect = { color = it }) + ListColorGrid(selected = color, onSelect = { color = it }) if (onDelete != null) { Spacer(Modifier.height(24.dp)) @@ -131,7 +131,7 @@ fun ListEditorSheet( /** The name, with the chosen colour beside it so the two read as one thing. */ @Composable -private fun NameField( +internal fun ListNameField( name: String, color: Int, autoFocus: Boolean, @@ -159,9 +159,12 @@ private fun NameField( } } -/** The palette as two rows of round swatches; the chosen one carries a check. */ +/** + * The palette as two rows of round swatches; the chosen one carries a check. + * Shared with the onboarding step that makes the user's first list. + */ @Composable -private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) { +internal fun ListColorGrid(selected: Int, onSelect: (Int) -> Unit) { val dark = isSystemInDarkTheme() GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { Column( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt new file mode 100644 index 0000000..31f7e58 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt @@ -0,0 +1,132 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +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.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.export.ExportOutcome +import de.jeanlucmakiola.agendula.ui.export.ExportViewModel +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * The device-only branch's last word: their lists live here and nowhere else, + * and the export that fixes that is one tap away. Runs the real export against + * the real picker, so whoever takes the offer leaves onboarding with an actual + * copy — and whoever doesn't has at least been shown where it lives. + */ +@Composable +internal fun BackupStep( + chrome: StepChrome, + onContinue: () -> Unit, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val saved = state.outcome is ExportOutcome.Success + + val folderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::exportToFolder) } + + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.SaveAlt) }, + actions = { + Button( + onClick = { if (saved) onContinue() else folderLauncher.launch(null) }, + enabled = !state.running, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource( + if (saved) R.string.onboarding_backup_continue else R.string.onboarding_backup_save, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + if (!saved) { + TextButton( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.onboarding_backup_skip)) + } + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_backup_title), + body = stringResource(R.string.onboarding_backup_body), + ) + + Spacer(Modifier.height(OnboardingSpace.lg)) + ExportOutcomeLine(state.running, state.outcome) + } +} + +/** What the export did, if it has been asked to do anything yet. */ +@Composable +private fun ExportOutcomeLine(running: Boolean, outcome: ExportOutcome?) { + when { + running -> StatusLine( + icon = null, + text = stringResource(R.string.export_running), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is ExportOutcome.Success -> StatusLine( + icon = Icons.Rounded.CheckCircle, + text = stringResource(R.string.onboarding_backup_saved), + color = MaterialTheme.colorScheme.primary, + ) + outcome is ExportOutcome.Failure -> StatusLine( + icon = Icons.Rounded.ErrorOutline, + text = stringResource(R.string.export_failed), + color = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +private fun StatusLine(icon: androidx.compose.ui.graphics.vector.ImageVector?, text: String, color: Color) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + ) { + if (icon == null) { + CircularProgressIndicator(Modifier.size(18.dp)) + } else { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt new file mode 100644 index 0000000..8e8a131 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt @@ -0,0 +1,81 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.lists.ListColorGrid +import de.jeanlucmakiola.agendula.ui.lists.ListNameField +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * Somewhere to put a task, so first run does not end on an empty screen. Only + * reached when the user has no lists at all, so there is no way past it — the + * name and colour come prefilled and the button is one tap. + * + * The list is always a device-local one. Creating a collection on a server is a + * protocol feature the app does not have yet; a synced user who lands here (an + * account whose server held no task collections) gets a local list beside it. + */ +@Composable +internal fun FirstListStep(chrome: StepChrome, onCreate: (name: String, color: Int) -> Unit) { + val suggested = stringResource(R.string.onboarding_list_default_name) + var name by rememberSaveable { mutableStateOf(suggested) } + var color by rememberSaveable { mutableIntStateOf(DefaultListColor) } + val commit = { if (name.isNotBlank()) onCreate(name, color) } + + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.Folder) }, + // The field and the palette carry their own inset. + contentPadding = 0.dp, + actions = { + Button( + onClick = commit, + enabled = name.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_list_create), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_list_title), + body = stringResource(R.string.onboarding_list_body), + horizontalPadding = GroupedListInset, + ) + + Spacer(Modifier.height(OnboardingSpace.lg)) + + // Prefilled and not focused: the step is a one-tap accept by default, and + // raising the keyboard over the palette would hide the other half of it. + ListNameField( + name = name, + color = color, + autoFocus = false, + onNameChange = { name = it }, + onImeAction = { commit() }, + ) + Spacer(Modifier.height(20.dp)) + ListColorGrid(selected = color, onSelect = { color = it }) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt new file mode 100644 index 0000000..9e7c422 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt @@ -0,0 +1,166 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +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.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen +import de.jeanlucmakiola.floret.components.OnboardingProgress +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * First run, end to end: welcome, reminders, the offer to sync, and then the + * loose ends that offer leaves — a list, a copy, the quick-add bar. Which steps + * run is [OnboardingViewModel]'s business; this only draws the one it is on. + * + * [OnboardingStep.Account] hands the whole screen to the add-account wizard, + * which already speaks `OnboardingScaffold` — it just needs to be told where in + * *this* flow its own three steps sit, so the progress stays one bar. + */ +@Composable +fun OnboardingFlow( + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + if (state.step == OnboardingStep.Account) { + AddAccountScreen( + onDone = { viewModel.onAccountFinished(added = true) }, + onBack = { viewModel.onAccountFinished(added = false) }, + stepOffset = state.position - 1, + totalSteps = state.total, + ) + return + } + + val chrome = StepChrome(state, onBack = viewModel::back, modifier = modifier) + + when (state.step) { + OnboardingStep.Welcome -> WelcomeStep(chrome, onContinue = viewModel::onWelcomeDone) + OnboardingStep.Reminders -> RemindersStep(chrome, onAnswered = viewModel::onRemindersAnswered) + OnboardingStep.SyncOffer -> SyncOfferStep(chrome, onAnswered = viewModel::onSyncAnswered) + OnboardingStep.FirstList -> FirstListStep(chrome, onCreate = viewModel::createFirstList) + OnboardingStep.Backup -> BackupStep(chrome, onContinue = viewModel::onBackupDone) + OnboardingStep.QuickAdd -> QuickAddStep( + chrome = chrome, + enabled = state.bottomAddBar, + onToggle = viewModel::setBottomAddBar, + onContinue = viewModel::finish, + ) + OnboardingStep.Account -> Unit + } +} + +/** + * The parts of a step's screen that belong to the flow rather than the step: + * where it sits in the progress, and whether there is a way back from it. Passed + * down so a step supplies only what is its own. + */ +@Immutable +internal data class StepChrome( + val state: OnboardingUiState, + val onBack: () -> Unit, + val modifier: Modifier, +) + +/** The family's onboarding shell with the flow's [chrome] already filled in. */ +@Composable +internal fun StepScaffold( + chrome: StepChrome, + hero: @Composable () -> Unit, + actions: @Composable ColumnScope.() -> Unit, + contentPadding: Dp = OnboardingSpace.md, + topSpacing: Dp = OnboardingSpace.md, + scrollingActions: Boolean = false, + body: @Composable ColumnScope.() -> Unit, +) { + OnboardingScaffold( + modifier = chrome.modifier, + hero = hero, + progress = { + OnboardingProgress( + step = chrome.state.position, + total = chrome.state.total, + label = stringResource( + R.string.add_account_step_of, + chrome.state.position, + chrome.state.total, + ), + ) + }, + navigationIcon = if (!chrome.state.canGoBack) { + null + } else { + { + IconButton(onClick = chrome.onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + }, + contentPadding = contentPadding, + topSpacing = topSpacing, + scrollingActions = scrollingActions, + actions = actions, + body = body, + ) +} + +/** + * A step's opening lines: an optional eyebrow, the headline, and one paragraph, + * centred above whatever the step asks for. [horizontalPadding] is for a step + * that passed `contentPadding = 0` because its own content carries the inset. + */ +@Composable +internal fun ColumnScope.StepHeader( + title: String, + body: String, + eyebrow: String? = null, + horizontalPadding: Dp = 0.dp, +) { + if (eyebrow != null) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 2.sp, + ) + Spacer(Modifier.height(OnboardingSpace.xs)) + } + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt similarity index 96% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt index 8795a00..2e960f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.agendula.ui.permission +package de.jeanlucmakiola.agendula.ui.onboarding import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..fc7f567 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,205 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.ui.accounts.ADD_ACCOUNT_STEPS +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A stop in the first-run flow. [Account] is the add-account wizard hosted + * inline, which is why it is worth more than one segment of the progress bar. + */ +enum class OnboardingStep { + Welcome, + Reminders, + SyncOffer, + Account, + FirstList, + Backup, + QuickAdd, + ; + + /** How many progress segments the step occupies. */ + val slots: Int get() = if (this == Account) ADD_ACCOUNT_STEPS else 1 +} + +data class OnboardingUiState( + val step: OnboardingStep = OnboardingStep.Welcome, + /** 1-based segment this step starts at, for [OnboardingStep.Account] its first. */ + val position: Int = 1, + val total: Int = 1, + val canGoBack: Boolean = false, + val bottomAddBar: Boolean = false, +) + +/** + * Drives first run: welcome, reminders, an offer to connect a CalDAV account, + * then whatever the answer to that leaves outstanding — a list to put tasks in, + * a copy of them off the device, and the quick-add bar. + * + * The flow branches, so the step list is computed rather than fixed: connecting + * an account replaces the backup step (the server *is* the copy), and the list + * step only appears for a user who would otherwise finish with nowhere to put a + * task — which a synced account usually settles on its own. + * + * Going back is offered only where nothing has been written yet. Once a list + * exists or an account has been added, the way on is forward. + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val prefs: SettingsPrefs, + private val repository: TasksRepository, +) : ViewModel() { + + /** Null until DataStore's first emission, so the flow neither flashes nor is skipped. */ + val done: StateFlow = prefs.onboardingDone + .map { it as Boolean? } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + private val hasLists = repository.taskLists() + .recoveringFromProviderFailure { emptyList() } + .map { it.isNotEmpty() } + + private val step = MutableStateFlow(OnboardingStep.Welcome) + + /** Answered at [OnboardingStep.SyncOffer]; null while it is still the question. */ + private val connectsAccount = MutableStateFlow(null) + + /** + * Snapshotted rather than observed: the list step decides whether it belongs + * in the flow *before* it runs, and must not vanish from under the user the + * moment their own list makes the answer false. Seeded from the store so the + * progress bar is honest from the first frame rather than resizing later. + */ + private val needsList = MutableStateFlow(true) + + init { + viewModelScope.launch { needsList.value = !hasLists.first() } + } + + val state: StateFlow = + combine(step, connectsAccount, needsList, prefs.settings) { step, sync, needsList, settings -> + val plan = plan(sync ?: false, needsList) + OnboardingUiState( + step = step, + position = plan.takeWhile { it != step }.sumOf { it.slots } + 1, + total = plan.sumOf { it.slots }, + canGoBack = canGoBack(step, sync), + bottomAddBar = settings.bottomAddBar, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), OnboardingUiState()) + + fun onWelcomeDone() = advanceTo(OnboardingStep.Reminders) + + /** Reminders default on; "not now" turns the in-app toggle off. */ + fun onRemindersAnswered(enabled: Boolean) { + viewModelScope.launch { + prefs.setRemindersEnabled(enabled) + advanceTo(OnboardingStep.SyncOffer) + } + } + + fun onSyncAnswered(connect: Boolean) { + connectsAccount.value = connect + if (connect) { + step.value = OnboardingStep.Account + } else { + advanceTo(OnboardingStep.FirstList) + } + } + + /** The wizard finished, or was abandoned from its own first step. */ + fun onAccountFinished(added: Boolean) { + if (!added) { + connectsAccount.value = null + step.value = OnboardingStep.SyncOffer + return + } + advanceTo(OnboardingStep.FirstList) + } + + fun createFirstList(name: String, color: Int) { + if (name.isBlank()) return + viewModelScope.launch { + runCatching { repository.createLocalList(name.trim(), color) } + afterFirstList() + } + } + + fun onBackupDone() = advanceTo(OnboardingStep.QuickAdd) + + fun setBottomAddBar(enabled: Boolean) { + viewModelScope.launch { prefs.setBottomAddBar(enabled) } + } + + fun finish() { + viewModelScope.launch { prefs.setOnboardingDone() } + } + + fun back() { + step.value = when (step.value) { + OnboardingStep.Reminders -> OnboardingStep.Welcome + OnboardingStep.SyncOffer -> OnboardingStep.Reminders + OnboardingStep.FirstList -> OnboardingStep.SyncOffer + else -> return + } + } + + /** + * Back is offered only where nothing has been written. The list step + * qualifies on the way through the local branch, but not when an account has + * just been added behind it — there is nothing back there to change. + */ + private fun canGoBack(step: OnboardingStep, sync: Boolean?): Boolean = when (step) { + OnboardingStep.Reminders, OnboardingStep.SyncOffer -> true + OnboardingStep.FirstList -> sync != true + else -> false + } + + /** + * Moves to [target], or past it when the flow no longer needs it. Only the + * list step is conditional, and only it re-reads the store: a synced account + * has usually brought lists of its own by now, and a restored backup may have + * done the same. + */ + private fun advanceTo(target: OnboardingStep) { + if (target != OnboardingStep.FirstList) { + step.value = target + return + } + viewModelScope.launch { + val already = hasLists.first() + needsList.value = !already + step.value = if (already) stepAfterFirstList() else OnboardingStep.FirstList + } + } + + private fun afterFirstList() { + step.value = stepAfterFirstList() + } + + private fun stepAfterFirstList(): OnboardingStep = + if (connectsAccount.value == true) OnboardingStep.QuickAdd else OnboardingStep.Backup + + private fun plan(sync: Boolean, needsList: Boolean): List = buildList { + add(OnboardingStep.Welcome) + add(OnboardingStep.Reminders) + add(OnboardingStep.SyncOffer) + if (sync) add(OnboardingStep.Account) + if (needsList) add(OnboardingStep.FirstList) + if (!sync) add(OnboardingStep.Backup) + add(OnboardingStep.QuickAdd) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt new file mode 100644 index 0000000..2050271 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt @@ -0,0 +1,104 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.tasklist.TaskListPreview +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OnboardingSpace +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.ViewPreviewFrame +import de.jeanlucmakiola.floret.components.positionOf + +/** + * The last step: how a list offers to add a task — the bar pinned to the bottom, + * or the floating button. Built like the family's other preview pickers: a live + * task list over connected grouped rows, where picking applies immediately and + * the preview is the confirmation, so the answer is given by looking rather than + * by imagining. + */ +@Composable +internal fun QuickAddStep( + chrome: StepChrome, + enabled: Boolean, + onToggle: (Boolean) -> Unit, + onContinue: () -> Unit, +) { + val options = listOf(true, false) + + StepScaffold( + chrome = chrome, + // Full-bleed: the preview plate and the rows carry the inset themselves. + contentPadding = 0.dp, + topSpacing = OnboardingSpace.xs, + // The preview gets the room it needs; Continue scrolls in under the + // options rather than pinning to the bottom. + scrollingActions = true, + hero = { + Text( + text = stringResource(R.string.onboarding_quick_add_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + ) + }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier + .padding(horizontal = GroupedListInset) + .fillMaxWidth() + .height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_quick_add_done), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + ViewPreviewFrame( + selected = enabled, + label = "quick-add-preview", + modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md), + ) { bottomAddBar -> + TaskListPreview(bottomAddBar = bottomAddBar, height = PreviewHeight) + } + + options.forEachIndexed { index, option -> + val isSelected = option == enabled + GroupedRow( + title = stringResource( + if (option) R.string.onboarding_quick_add_bar else R.string.onboarding_quick_add_button, + ), + summary = stringResource( + if (option) { + R.string.onboarding_quick_add_bar_hint + } else { + R.string.onboarding_quick_add_button_hint + }, + ), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onToggle(option) }, + ) + } + } +} + +/** The size the family's other preview pickers give it. */ +private val PreviewHeight: Dp = 280.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt similarity index 66% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt index 80242d0..7c370d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.agendula.ui.permission +package de.jeanlucmakiola.agendula.ui.onboarding import android.Manifest import android.os.Build @@ -18,34 +18,29 @@ 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.agendula.R import de.jeanlucmakiola.floret.components.BenefitRow -import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingSpace /** - * One-time onboarding step after the tasks-provider grant: explains that Agendula - * delivers due reminders itself (tasks providers don't broadcast reminders) and - * requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only). + * Offers due reminders and requests `POST_NOTIFICATIONS` (a system dialog on + * API 33+ only). Framed as Agendula's own feature: it schedules and delivers + * them itself, which is also what makes it work over an external store, since + * no tasks provider broadcasts reminders. * - * Reminders default ON: [onFinished] gets true from the primary action even if + * Reminders default ON: [onAnswered] gets true from the primary action even if * the system dialog is declined — the OS permission is the real gate, and the * Settings toggle re-requests it. "Not now" turns the in-app toggle off. */ @Composable -fun ReminderOnboardingScreen( - onFinished: (remindersEnabled: Boolean) -> Unit, - modifier: Modifier = Modifier, -) { +internal fun RemindersStep(chrome: StepChrome, onAnswered: (enabled: Boolean) -> Unit) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), - ) { onFinished(true) } + ) { onAnswered(true) } - OnboardingScaffold( - modifier = modifier, + StepScaffold( + chrome = chrome, hero = { SquircleHero(Icons.Rounded.Notifications) }, actions = { Button( @@ -53,7 +48,7 @@ fun ReminderOnboardingScreen( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { - onFinished(true) + onAnswered(true) } }, modifier = Modifier.fillMaxWidth().height(56.dp), @@ -64,31 +59,16 @@ fun ReminderOnboardingScreen( ) } TextButton( - onClick = { onFinished(false) }, + onClick = { onAnswered(false) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.reminder_onboarding_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.reminder_onboarding_title), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(R.string.reminder_onboarding_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + StepHeader( + title = stringResource(R.string.reminder_onboarding_title), + body = stringResource(R.string.reminder_onboarding_body), ) Spacer(Modifier.height(OnboardingSpace.xl)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt new file mode 100644 index 0000000..9f64819 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Backup +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.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.BenefitRow +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * The fork: connect a CalDAV account now, or stay on the device. Saying yes + * hands the next three steps to the add-account wizard; saying no leaves a list + * to make and a copy to think about. + */ +@Composable +internal fun SyncOfferStep(chrome: StepChrome, onAnswered: (connect: Boolean) -> Unit) { + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.CloudSync) }, + actions = { + Button( + onClick = { onAnswered(true) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_sync_connect), + style = MaterialTheme.typography.titleMedium, + ) + } + TextButton( + onClick = { onAnswered(false) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.onboarding_sync_skip)) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_sync_title), + body = stringResource(R.string.onboarding_sync_body), + ) + + Spacer(Modifier.height(OnboardingSpace.xl)) + + BenefitRow( + icon = Icons.Rounded.Devices, + title = stringResource(R.string.onboarding_sync_devices_title), + body = stringResource(R.string.onboarding_sync_devices_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.Backup, + title = stringResource(R.string.onboarding_sync_copy_title), + body = stringResource(R.string.onboarding_sync_copy_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.Lock, + title = stringResource(R.string.onboarding_sync_yours_title), + body = stringResource(R.string.onboarding_sync_yours_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt new file mode 100644 index 0000000..4c74bc7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.BenefitRow +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** What the app is, before it asks for anything. */ +@Composable +internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) { + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.Checklist) }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_welcome_button), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + StepHeader( + eyebrow = stringResource(R.string.app_name).uppercase(), + title = stringResource(R.string.onboarding_welcome_title), + body = stringResource(R.string.onboarding_welcome_body), + ) + + Spacer(Modifier.height(OnboardingSpace.xl)) + + BenefitRow( + icon = Icons.Rounded.Checklist, + title = stringResource(R.string.onboarding_welcome_lists_title), + body = stringResource(R.string.onboarding_welcome_lists_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.NotificationsActive, + title = stringResource(R.string.onboarding_welcome_reminders_title), + body = stringResource(R.string.onboarding_welcome_reminders_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.CloudSync, + title = stringResource(R.string.onboarding_welcome_sync_title), + body = stringResource(R.string.onboarding_welcome_sync_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt deleted file mode 100644 index cac8cda..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.jeanlucmakiola.agendula.ui.permission - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -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 shown after the tasks-provider - * 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, -) : 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 due reminders stay on. */ - fun finish(remindersEnabled: Boolean) { - viewModelScope.launch { - prefs.setRemindersEnabled(remindersEnabled) - prefs.setReminderOnboardingDone() - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt new file mode 100644 index 0000000..6bab6be --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt @@ -0,0 +1,125 @@ +package de.jeanlucmakiola.agendula.ui.tasklist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +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.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.floret.components.ScaledViewPreview +import de.jeanlucmakiola.floret.components.positionOf + +/** + * A task list as it will look, for a chooser that offers a choice about it — + * today only the bottom quick-add bar against the floating "New task" button. + * + * Drawn from the screen's own [TaskRowContent] and [InlineAdd] over sample + * tasks, so what the preview shows cannot drift from what the app does. The + * chrome around them (a title, the FAB) is a still of the real scaffold rather + * than the scaffold itself: [TaskListScreen] needs a ViewModel, a list id and a + * store behind it, none of which exist during onboarding. + */ +@Composable +internal fun TaskListPreview(bottomAddBar: Boolean, height: Dp) { + ScaledViewPreview(height = height) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.tasks_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 16.dp), + ) + SampleTasks.forEachIndexed { index, task -> + TaskRowContent( + task = task, + position = positionOf(index, SampleTasks.size), + showListName = false, + subtaskDone = task.subtaskDone, + subtaskTotal = task.subtaskTotal, + expanded = false, + onToggleExpand = null, + onToggle = {}, + onClick = {}, + ) + if (index != SampleTasks.lastIndex) Spacer(Modifier.height(2.dp)) + } + } + + if (bottomAddBar) { + Box(Modifier.align(Alignment.BottomCenter)) { + InlineAdd(onAdd = {}, elevated = true) + } + } else { + ExtendedFloatingActionButton( + onClick = {}, + icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.new_task)) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } + } + } +} + +/** Enough rows to fill the viewport, and varied enough to show a meta line. */ +private val SampleTasks: List = listOf( + sampleTask(1, "Water the balcony plants", priority = Priority.MEDIUM), + sampleTask(2, "Book the dentist", subtaskTotal = 3, subtaskDone = 1), + sampleTask(3, "Reply to the landlord"), + sampleTask(4, "Pick up the parcel", status = TaskStatus.COMPLETED), +) + +private fun sampleTask( + id: Long, + title: String, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + subtaskTotal: Int = 0, + subtaskDone: Int = 0, +) = Task( + taskId = id, + listId = 1, + title = title, + description = null, + location = null, + url = null, + priority = priority, + status = status, + percentComplete = null, + start = null, + due = null, + isAllDay = false, + timeZone = null, + completedAt = null, + listColor = DefaultListColor, + taskColor = null, + listName = null, + accountName = null, + parentId = null, + isRecurring = false, + distanceFromCurrent = null, + created = null, + lastModified = null, + subtaskTotal = subtaskTotal, + subtaskDone = subtaskDone, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 5bcca82..e81aad9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -444,10 +444,13 @@ private fun TaskListBody( } } -/** Inline "add a task" — title only, into the current list. */ +/** + * Inline "add a task" — title only, into the current list. Shared with the + * onboarding step that previews the bottom bar this sits in. + */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { +internal fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { var text by remember { mutableStateOf("") } fun submit() { if (text.isNotBlank()) { @@ -569,7 +572,7 @@ private fun TaskRow( @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun TaskRowContent( +internal fun TaskRowContent( task: Task, position: Position, showListName: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89da8e7..d9bf11b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -178,22 +178,67 @@ A tasks app is needed - Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started. + You chose to keep tasks in another app, but no compatible one is installed. Install OpenTasks or tasks.org, or switch back to Agendula\'s own storage. Allow access to your tasks Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for. Grant task access Install OpenTasks Install tasks.org + + Everything you meant to do + A calm place for your tasks, on a phone that stays yours. Let\'s set it up — it takes a minute. + Get started + Lists and subtasks + Keep work, home and a project apart, and break the big ones down. + Reminders that arrive + A notification when something is due, on your schedule. + Your server, or none at all + Sync over CalDAV with a server you choose, or keep everything on this device. + + + Sync with your own server? + Agendula talks CalDAV — Nextcloud, Radicale, Posteo, Fastmail and anything else that speaks it. No account of ours, ever. + Connect an account + Not now + Every device at once + Tick something off here and it is ticked off everywhere. + A copy that survives the phone + Your tasks live on the server too, so a lost device is not lost work. + Yours to choose + Self-hosted or a provider you already pay — Agendula only stores the password. + + + Your first list + Lists keep things apart. Start with one — you can add more, and rename this one, any time. + Personal + Create list + + + Keep a copy + Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read. + Save a copy now + Not now + Continue + Saved. You can do this again any time from Settings. + + + How would you like to add tasks? + Quick add bar + A field at the bottom of every list: type, hit enter, on to the next one. + New task button + A floating button that opens the full form. + Finish + - Never miss what\'s due - Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due. + Stay on top of your tasks + Agendula notifies you when something is due, so nothing has to live in your head. Enable reminders Not now - Agendula reminds you - It schedules a notification for each task with a due date. + A nudge when it matters + Every task with a due date gets its own notification. On your schedule - Choose how far ahead to be reminded in Settings. + Choose how far ahead to be reminded — for everything, or per list. Change it anytime Turn reminders off whenever you like — it\'s just a switch.