onboarding: a real first run, not one screen asking for notifications
First run was a bell screen followed by an empty lists screen. It is now
welcome → reminders → an offer to connect a CalDAV account, and then
whatever that answer leaves outstanding:
yes → the add-account wizard inline, its three steps reporting their
place in the outer progress via stepOffset/totalSteps
no → a first list, then the offer to export a copy
Both end on how a list adds tasks — a preview picker over the real task row
and quick-add field, so the answer is given by looking. The list step only
appears when the user has no lists at all, which covers a server that held
no task collections and a restored backup alike; the backup step never
shows on the synced branch, where the server is the copy.
The External-store gates are rebuilt on the same shell, and their copy no
longer claims Agendula only shows tasks that OpenTasks or tasks.org stores.
The reminder step drops the same framing. The done flag keeps its old key,
so existing installs are not dragged back through it.
This commit is contained in:
@@ -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<Boolean> = 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<Boolean> = 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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
@@ -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<Boolean?> = 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<Boolean?>(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<OnboardingUiState> =
|
||||
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<OnboardingStep> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+15
-35
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
-39
@@ -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<Boolean?> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Task> = 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -178,22 +178,67 @@
|
||||
|
||||
<!-- Provider / permission onboarding -->
|
||||
<string name="onboarding_no_provider_title">A tasks app is needed</string>
|
||||
<string name="onboarding_no_provider_body">Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
|
||||
<string name="onboarding_no_provider_body">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.</string>
|
||||
<string name="onboarding_permission_title">Allow access to your tasks</string>
|
||||
<string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
|
||||
<string name="onboarding_permission_button">Grant task access</string>
|
||||
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
||||
|
||||
<!-- First run: welcome -->
|
||||
<string name="onboarding_welcome_title">Everything you meant to do</string>
|
||||
<string name="onboarding_welcome_body">A calm place for your tasks, on a phone that stays yours. Let\'s set it up — it takes a minute.</string>
|
||||
<string name="onboarding_welcome_button">Get started</string>
|
||||
<string name="onboarding_welcome_lists_title">Lists and subtasks</string>
|
||||
<string name="onboarding_welcome_lists_body">Keep work, home and a project apart, and break the big ones down.</string>
|
||||
<string name="onboarding_welcome_reminders_title">Reminders that arrive</string>
|
||||
<string name="onboarding_welcome_reminders_body">A notification when something is due, on your schedule.</string>
|
||||
<string name="onboarding_welcome_sync_title">Your server, or none at all</string>
|
||||
<string name="onboarding_welcome_sync_body">Sync over CalDAV with a server you choose, or keep everything on this device.</string>
|
||||
|
||||
<!-- First run: sync offer -->
|
||||
<string name="onboarding_sync_title">Sync with your own server?</string>
|
||||
<string name="onboarding_sync_body">Agendula talks CalDAV — Nextcloud, Radicale, Posteo, Fastmail and anything else that speaks it. No account of ours, ever.</string>
|
||||
<string name="onboarding_sync_connect">Connect an account</string>
|
||||
<string name="onboarding_sync_skip">Not now</string>
|
||||
<string name="onboarding_sync_devices_title">Every device at once</string>
|
||||
<string name="onboarding_sync_devices_body">Tick something off here and it is ticked off everywhere.</string>
|
||||
<string name="onboarding_sync_copy_title">A copy that survives the phone</string>
|
||||
<string name="onboarding_sync_copy_body">Your tasks live on the server too, so a lost device is not lost work.</string>
|
||||
<string name="onboarding_sync_yours_title">Yours to choose</string>
|
||||
<string name="onboarding_sync_yours_body">Self-hosted or a provider you already pay — Agendula only stores the password.</string>
|
||||
|
||||
<!-- First run: the first list -->
|
||||
<string name="onboarding_list_title">Your first list</string>
|
||||
<string name="onboarding_list_body">Lists keep things apart. Start with one — you can add more, and rename this one, any time.</string>
|
||||
<string name="onboarding_list_default_name">Personal</string>
|
||||
<string name="onboarding_list_create">Create list</string>
|
||||
|
||||
<!-- First run: backup -->
|
||||
<string name="onboarding_backup_title">Keep a copy</string>
|
||||
<string name="onboarding_backup_body">Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read.</string>
|
||||
<string name="onboarding_backup_save">Save a copy now</string>
|
||||
<string name="onboarding_backup_skip">Not now</string>
|
||||
<string name="onboarding_backup_continue">Continue</string>
|
||||
<string name="onboarding_backup_saved">Saved. You can do this again any time from Settings.</string>
|
||||
|
||||
<!-- First run: quick add bar -->
|
||||
<string name="onboarding_quick_add_title">How would you like to add tasks?</string>
|
||||
<string name="onboarding_quick_add_bar">Quick add bar</string>
|
||||
<string name="onboarding_quick_add_bar_hint">A field at the bottom of every list: type, hit enter, on to the next one.</string>
|
||||
<string name="onboarding_quick_add_button">New task button</string>
|
||||
<string name="onboarding_quick_add_button_hint">A floating button that opens the full form.</string>
|
||||
<string name="onboarding_quick_add_done">Finish</string>
|
||||
|
||||
<!-- Reminder onboarding (one-time, after the provider grant) -->
|
||||
<string name="reminder_onboarding_title">Never miss what\'s due</string>
|
||||
<string name="reminder_onboarding_body">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.</string>
|
||||
<string name="reminder_onboarding_title">Stay on top of your tasks</string>
|
||||
<string name="reminder_onboarding_body">Agendula notifies you when something is due, so nothing has to live in your head.</string>
|
||||
<string name="reminder_onboarding_enable_button">Enable reminders</string>
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_benefit_delivery_title">Agendula reminds you</string>
|
||||
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
|
||||
<string name="reminder_benefit_delivery_title">A nudge when it matters</string>
|
||||
<string name="reminder_benefit_delivery_body">Every task with a due date gets its own notification.</string>
|
||||
<string name="reminder_benefit_timing_title">On your schedule</string>
|
||||
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
|
||||
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded — for everything, or per list.</string>
|
||||
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user