From a454c3685e579e1d8191455ce65e3f3d18fa7a7d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 14:27:15 +0200 Subject: [PATCH] Align motion and settings with Calendula (#66) Screens slide in and leave through the predictive-back exit, settings sub-screens get Calendula's back preview, Reminders becomes Notifications, and export/import move out of Storage into Backup & restore. Bumps floret-kit for the language picker header. --- CHANGELOG.md | 7 + README.md | 2 +- .../ui/accounts/AccountDetailScreen.kt | 3 +- .../agendula/ui/accounts/AccountsScreen.kt | 3 + .../agendula/ui/settings/AccountSections.kt | 9 +- app/src/main/AndroidManifest.xml | 1 + .../agendula/ui/export/ExportScreen.kt | 3 + .../agendula/ui/imports/ImportScreen.kt | 4 + .../agendula/ui/licences/LicencesScreen.kt | 4 +- .../agendula/ui/navigation/AgendulaNavHost.kt | 25 ++- .../agendula/ui/settings/AppearanceScreen.kt | 8 +- .../agendula/ui/settings/BackupScreen.kt | 37 ++++ ...indersScreen.kt => NotificationsScreen.kt} | 10 +- .../agendula/ui/settings/SettingsScreen.kt | 178 ++++++++---------- .../agendula/ui/settings/StorageScreen.kt | 29 ++- .../agendula/ui/settings/TaskFormScreen.kt | 8 +- app/src/main/res/values/strings.xml | 26 +-- floret-kit | 2 +- 18 files changed, 214 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt rename app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/{RemindersScreen.kt => NotificationsScreen.kt} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1113192..03a357a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ All notable changes to this project are documented here. The format follows those tasks, including their reminders and tags. They can tick off a single occurrence of a recurring task. Task lists stay read-only for them (#63). +### Changed +- Screens now open and close the way they do in Calendula: they slide in, and + a back swipe previews the screen behind before closing (#66). +- Settings now use Calendula's names and layout. Reminders is now + Notifications, Task form is now New task form, and export and import moved + out of Storage into a new Backup & restore section (#66). + ## [1.1.0] - 2026-10-10 ### Added diff --git a/README.md b/README.md index adc1121..63c9506 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Agendula's own database go with it, so sync them to a CalDAV account first. Each Codeberg release also carries `agendula-offline_v.apk`: Agendula without CalDAV sync and **without the network permission**, so it cannot connect to anything. It installs alongside the regular app rather than replacing -it; move tasks between the two with Settings → Storage → Export / Import. In +it; move tasks between the two with Settings → Backup & restore → Export / Import. In Obtainium, pick it with *Filter APKs by regular expression* set to `^agendula-offline_v`. diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt index b23322b..1a88396 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -70,6 +70,7 @@ internal fun AccountDetailScreen( onSignInAgain: (AccountEntity) -> Unit, onOpenStorage: () -> Unit, viewModel: AccountsViewModel, + visible: Boolean, ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() @@ -90,7 +91,7 @@ internal fun AccountDetailScreen( // shown on the accounts list rather than here. var confirming by remember { mutableStateOf(false) } - CollapsingScaffold(title = identity.title, onBack = onBack) { + CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) { AccountHero(identity) Spacer(Modifier.height(24.dp)) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt index a45c598..a4467d5 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt @@ -48,6 +48,7 @@ internal fun AccountsScreen( onOpenStorage: () -> Unit, onBack: () -> Unit, viewModel: AccountsViewModel, + visible: Boolean, ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() @@ -60,6 +61,8 @@ internal fun AccountsScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_accounts), onBack = onBack, + predictiveBack = true, + visible = visible, ) { val loaded = accounts ?: return@CollapsingScaffold diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt index e2bb836..8e15c79 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -31,8 +31,8 @@ internal fun AccountSections( // Which account the detail screen is showing; the section alone cannot say. var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) } - // Accounts stays composed under Add account, for the same reason Storage - // stays composed under Export: the deeper screen slides over it. + // Accounts stays composed under Add account, for the same reason Backup + // stays composed under export and import: the deeper screen slides over it. val accountsOpen = section == SettingsSection.Accounts || section?.parent == SettingsSection.Accounts SlideInSection(visible = accountsOpen) { @@ -50,9 +50,11 @@ internal fun AccountSections( onOpenStorage = { onSection(SettingsSection.Storage) }, onBack = { onSection(null) }, viewModel = accountsViewModel, + visible = accountsOpen, ) } - SlideInSection(visible = section == SettingsSection.Account) { + val accountOpen = section == SettingsSection.Account + SlideInSection(visible = accountOpen) { openAccount?.let { id -> AccountDetailScreen( accountId = id, @@ -61,6 +63,7 @@ internal fun AccountSections( onSignInAgain = signInAgain, onOpenStorage = { onSection(SettingsSection.Storage) }, viewModel = accountsViewModel, + visible = accountOpen, ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bca0623..0276b0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ Unit, + visible: Boolean, modifier: Modifier = Modifier, viewModel: ExportViewModel = hiltViewModel(), ) { @@ -89,6 +90,8 @@ fun ExportScreen( title = stringResource(R.string.settings_export), onBack = onBack, modifier = modifier, + predictiveBack = true, + visible = visible, ) { Text( text = stringResource(R.string.export_hint), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt index 0818e2f..85bfe03 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt @@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati fun ImportScreen( onBack: () -> Unit, modifier: Modifier = Modifier, + predictiveBack: Boolean = false, + visible: Boolean? = null, initialUri: Uri? = null, onOpenStorage: (() -> Unit)? = null, viewModel: ImportViewModel = hiltViewModel(), @@ -105,6 +107,8 @@ fun ImportScreen( title = stringResource(R.string.import_title), onBack = onBack, modifier = modifier, + predictiveBack = predictiveBack, + visible = visible, ) { Text( text = stringResource(R.string.import_hint), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt index 84c86ab..f808271 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt @@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf * source, which is the specific thing §3.2(a) requires us to point at. */ @Composable -internal fun LicencesScreen(onBack: () -> Unit) { +internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) { val uriHandler = LocalUriHandler.current CollapsingScaffold( title = stringResource(R.string.licences_title), onBack = onBack, + predictiveBack = true, + visible = visible, ) { Text( stringResource(R.string.licences_intro), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index 4bde49d..a86553e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import de.jeanlucmakiola.agendula.domain.Task import kotlin.time.Instant @@ -27,6 +29,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel +import de.jeanlucmakiola.floret.identity.predictiveBackExit /** * The single in-app back stack: lists → task list → detail / edit. @@ -37,6 +40,7 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel * `SavedStateHandle`). Screens receive plain navigation lambdas so they stay * unaware of the nav library. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AgendulaNavHost( modifier: Modifier = Modifier, @@ -70,19 +74,22 @@ fun AgendulaNavHost( onNavRequestConsumed() } + val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec() + val overlayExit = predictiveBackExit() + NavHost( navController = nav, startDestination = Dest.LISTS, modifier = modifier, - // Only the screen on top moves; the background is always held static. - // Forward is a plain fade-in over the held screen — calm, not a slide. - // Back is the predictive-back peek: navigation-compose composes the - // screen being revealed underneath and seeks popExit with the gesture, so - // the leaving screen scales down to reveal the (unmoved) previous screen. - enterTransition = { fadeIn() }, + // Calendula's overlay motion: only the screen on top moves. It slides in + // over the held screen and leaves the way floret's predictiveBackExit + // does. A back swipe seeks that exit, with the previous screen drawn + // underneath — which only NavHost's own handler can do, so destinations + // must not register a preview of their own. + enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() }, + popExitTransition = { overlayExit }, ) { composable(Dest.LISTS) { ReadableWidth { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt index b4137bc..7c1ce72 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt @@ -34,6 +34,7 @@ internal fun AppearanceScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S var showTheme by remember { mutableStateOf(false) } @@ -42,7 +43,12 @@ internal fun AppearanceScreen( val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) val locale = currentLocale() - CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_appearance), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { GroupedRow( title = stringResource(R.string.settings_theme), summary = stringResource(themeLabel(state.settings.themeMode)), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt new file mode 100644 index 0000000..0cd7175 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/BackupScreen.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position + +/** Backup & restore: the way into export and import, as its own entry (after Calendula). */ +@Composable +internal fun BackupScreen( + onOpenExport: () -> Unit, + onOpenImport: () -> Unit, + onBack: () -> Unit, + visible: Boolean, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_backup), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Top, + onClick = onOpenExport, + ) + GroupedRow( + title = stringResource(R.string.import_title), + summary = stringResource(R.string.settings_import_hint), + position = Position.Bottom, + onClick = onOpenImport, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt similarity index 98% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt index f852078..c21250a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt @@ -63,10 +63,11 @@ import java.time.LocalTime /** Whether due tasks notify at all, how far ahead, and which lists differ. */ @Composable -internal fun RemindersScreen( +internal fun NotificationsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { val context = LocalContext.current var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) } @@ -119,7 +120,12 @@ internal fun RemindersScreen( ) val lists = state.lists.filter { it.id > 0L } - CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_reminders), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { if (state.settings.remindersEnabled && !notificationsAllowed) { GroupedRow( title = stringResource(R.string.settings_notifications_blocked), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 601b115..f71e3bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -1,11 +1,8 @@ package de.jeanlucmakiola.agendula.ui.settings -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -13,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Favorite @@ -25,6 +23,7 @@ import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,20 +50,22 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.LanguagePickerRow import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport -import de.jeanlucmakiola.floret.locale.AppLanguage +import de.jeanlucmakiola.floret.identity.predictiveBackExit /** The settings sub-screens reached from the hub's category rows. */ internal enum class SettingsSection { Appearance, TaskForm, - Reminders, + Notifications, Storage, + Backup, Export, Import, Accounts, @@ -72,10 +74,10 @@ internal enum class SettingsSection { Licences, ; - /** Where back goes: Export is opened from Storage, the account screens from Accounts. */ + /** Where back goes: export and import are opened from Backup, the account screens from Accounts. */ val parent: SettingsSection? get() = when (this) { - Export, Import -> Storage + Export, Import -> Backup AddAccount, Account -> Accounts else -> null } @@ -88,8 +90,9 @@ internal enum class SettingsSection { * A full-screen destination; [onBack] pops it. [initialSection] names a * sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]). * - * Every sub-screen is its own file. This one owns the hub, the section enum and - * the back rules between them, and nothing else. + * Every sub-screen is its own file and handles its own back through the + * predictive-back preview. This one owns the hub, the section enum and the + * routing between them, and nothing else. */ @Composable fun SettingsScreen( @@ -107,10 +110,6 @@ fun SettingsScreen( ) } - // Inside a sub-screen, system back (button or gesture) returns to the hub - // rather than popping the whole Settings destination to the lists overview. - BackHandler(enabled = section != null) { section = section?.parent } - Box( modifier = modifier .fillMaxSize() @@ -118,34 +117,48 @@ fun SettingsScreen( ) { SettingsHub(onBack = onBack, onOpenSection = { section = it }) - SlideInSection(visible = section == SettingsSection.Appearance) { - AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val appearance = section == SettingsSection.Appearance + SlideInSection(visible = appearance) { + AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance) } - SlideInSection(visible = section == SettingsSection.TaskForm) { - TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val taskForm = section == SettingsSection.TaskForm + SlideInSection(visible = taskForm) { + TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm) } - SlideInSection(visible = section == SettingsSection.Reminders) { - RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) + val notifications = section == SettingsSection.Notifications + SlideInSection(visible = notifications) { + NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = notifications) } - // Storage stays composed under Export, so the deeper screen slides over it. - val storageOpen = section == SettingsSection.Storage || - section?.parent == SettingsSection.Storage - SlideInSection(visible = storageOpen) { - StorageScreen( - viewModel = viewModel, + val storage = section == SettingsSection.Storage + SlideInSection(visible = storage) { + StorageScreen(viewModel = viewModel, onBack = { section = null }, visible = storage) + } + // Backup stays composed under export and import, so the deeper screen slides over it. + val backup = section == SettingsSection.Backup || section?.parent == SettingsSection.Backup + SlideInSection(visible = backup) { + BackupScreen( onOpenExport = { section = SettingsSection.Export }, onOpenImport = { section = SettingsSection.Import }, onBack = { section = null }, + visible = backup, ) } - SlideInSection(visible = section == SettingsSection.Export) { - ExportScreen(onBack = { section = SettingsSection.Storage }) + val exportOpen = section == SettingsSection.Export + SlideInSection(visible = exportOpen) { + ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen) } - SlideInSection(visible = section == SettingsSection.Import) { - ImportScreen(onBack = { section = SettingsSection.Storage }) + val importOpen = section == SettingsSection.Import + SlideInSection(visible = importOpen) { + ImportScreen( + onBack = { section = SettingsSection.Backup }, + onOpenStorage = { section = SettingsSection.Storage }, + predictiveBack = true, + visible = importOpen, + ) } - SlideInSection(visible = section == SettingsSection.Licences) { - LicencesScreen(onBack = { section = null }) + val licences = section == SettingsSection.Licences + SlideInSection(visible = licences) { + LicencesScreen(onBack = { section = null }, visible = licences) } AccountSections( section = section, @@ -155,12 +168,18 @@ fun SettingsScreen( } } +/** + * A sub-screen layered over the hub, with Calendula's overlay motion: it slides + * in on the motion scheme's spring and leaves from its predictive-back preview. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { + val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec() AnimatedVisibility( visible = visible, - enter = slideInHorizontally { it } + fadeIn(), - exit = slideOutHorizontally { it } + fadeOut(), + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = predictiveBackExit(), ) { content() } } @@ -208,7 +227,7 @@ private fun SettingsHub( summary = stringResource(R.string.settings_reminders_subtitle), position = Position.Bottom, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, - onClick = { onOpenSection(SettingsSection.Reminders) }, + onClick = { onOpenSection(SettingsSection.Notifications) }, ) Spacer(Modifier.height(8.dp)) @@ -225,14 +244,41 @@ private fun SettingsHub( GroupedRow( title = stringResource(R.string.settings_section_storage), summary = stringResource(R.string.settings_storage_subtitle), - position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone, - leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, + position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Storage) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_backup), + summary = stringResource(R.string.settings_backup_subtitle), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) }, + onClick = { onOpenSection(SettingsSection.Backup) }, + ) Spacer(Modifier.height(8.dp)) GroupedSectionHeader(stringResource(R.string.settings_group_app)) - LanguageRow(position = Position.Top) + LanguagePickerRow( + position = Position.Top, + title = stringResource(R.string.settings_language), + autoLabel = stringResource(R.string.settings_language_auto), + localesConfig = R.xml.locales_config, + predictiveBack = true, + // Invite contributions right where users pick their language. + header = { + val context = LocalContext.current + val translateUrl = stringResource(R.string.about_translate_url) + GroupedRow( + title = stringResource(R.string.settings_translate), + summary = stringResource(R.string.settings_translate_hint), + position = Position.Alone, + leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, + onClick = { openUrl(context, translateUrl) }, + ) + Spacer(Modifier.height(16.dp)) + }, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, + ) ReportProblemRow(position = Position.Bottom) // Source, licence and privacy sit at the bottom as reference material @@ -316,64 +362,6 @@ private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position ) } -/** - * The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the - * picker it opens carries a "Help translate" header, and inviting contributions - * right where a user goes looking for their language is app-specific framing, - * not a family primitive. Everything else matches that recipe. - */ -@Composable -private fun LanguageRow(position: Position) { - val context = LocalContext.current - val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) } - // Setting a locale recreates the activity; mirror the choice locally so the - // row updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } - var showDialog by remember { mutableStateOf(false) } - - // null = follow the system; the rest are BCP-47 tags from locales_config.xml. - val options = remember(supported) { listOf(null) + supported } - - GroupedRow( - title = stringResource(R.string.settings_language), - summary = languageLabel(current), - position = position, - leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, - onClick = { showDialog = true }, - ) - - if (showDialog) { - OptionPicker( - title = stringResource(R.string.settings_language), - predictiveBack = true, - options = options, - selected = current, - label = { languageLabel(it) }, - onSelect = { - current = it - AppLanguage.apply(it) - }, - onDismiss = { showDialog = false }, - // Invite contributions right where users pick their language. - header = { - val translateUrl = stringResource(R.string.about_translate_url) - GroupedRow( - title = stringResource(R.string.settings_translate), - summary = stringResource(R.string.settings_translate_hint), - position = Position.Alone, - leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, - onClick = { openUrl(context, translateUrl) }, - ) - Spacer(Modifier.height(16.dp)) - }, - ) - } -} - -@Composable -private fun languageLabel(tag: String?): String = - if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) - /** * Opens the project's issue tracker to report a problem. If a crash report was * captured (and not yet sent), it surfaces that report first via the same diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt index 62ebe04..088bea9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck @Composable internal fun StorageScreen( viewModel: SettingsViewModel, - onOpenExport: () -> Unit, - onOpenImport: () -> Unit, onBack: () -> Unit, + visible: Boolean, ) { val context = LocalContext.current val storage by viewModel.storage.collectAsStateWithLifecycle() @@ -85,11 +84,17 @@ internal fun StorageScreen( // permission dialog too, and would wipe the refusal before it is read. OnResume { viewModel.refreshStorage() } - CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_storage), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { + val canCopy = storage?.canCopyFromExternal == true GroupedRow( title = stringResource(R.string.settings_task_store), summary = storage?.let { storeLabel(it) }, - position = Position.Top, + position = if (canCopy) Position.Top else Position.Alone, // Nothing to pick until the stored mode has landed; opening the picker // on the seeded state would offer the wrong store as the current one. onClick = storage?.let { @@ -99,31 +104,19 @@ internal fun StorageScreen( } }, ) - if (storage?.canCopyFromExternal == true) { + if (canCopy) { GroupedRow( title = stringResource( R.string.settings_copy_from_external, storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(), ), summary = stringResource(R.string.settings_copy_from_external_hint), - position = Position.Middle, + position = Position.Bottom, leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) }, // Inert while a copy is in flight; the receipt below says so. onClick = if (transfer.running) null else viewModel::startCopyFromExternal, ) } - GroupedRow( - title = stringResource(R.string.settings_export), - summary = stringResource(R.string.settings_export_hint), - position = Position.Middle, - onClick = onOpenExport, - ) - GroupedRow( - title = stringResource(R.string.import_title), - summary = stringResource(R.string.settings_import_hint), - position = Position.Bottom, - onClick = onOpenImport, - ) TransferStatus(transfer) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt index cd1c891..2f4ddbb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -42,10 +42,16 @@ internal fun TaskFormScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean, ) { var showDefaultList by remember { mutableStateOf(false) } - CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_task_form), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) val fields = TaskFormField.entries diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5987293..f3d7bd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,7 @@ Days Weeks Per-list reminders - Override the default reminder for individual lists + Override the default per list — separately for timed and all-day tasks. A list can keep the default, drop it, or set its own. Notifications Sound, vibration and importance for this list’s reminders Default (%1$s) @@ -359,7 +359,7 @@ 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, any time from Settings → Storage → Export. + Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Backup & restore → Export. Continue @@ -394,10 +394,10 @@ About Appearance - Theme, colour and time format - Task form - Default fields, list and subtasks - Notifications for due tasks + Theme, colours, time format + New task form + Default fields and behaviour + Reminders and delivery Fields shown by default on a new task. The rest sit behind \"More fields\". Available on Android 12 and later Focus title on new task @@ -442,13 +442,13 @@ Dark Dynamic colour Use colours from your wallpaper - Reminders - Due reminders + Notifications + Task reminders Notify me when a task is due Notifications are off Reminders can\u2019t reach you. Tap to allow notifications for Agendula. - When to remind - Time for all-day reminders + Default reminder + All-day reminder time All-day tasks count back from %1$s All-day tasks Notifies at %1$s @@ -472,9 +472,11 @@ Storage - Where tasks are kept, and export + Where your tasks are kept + Backup & restore + Export and import Task store - Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them. + Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them from Backup & restore. On this device Agendula\'s own storage. Nothing else to install. Another task app diff --git a/floret-kit b/floret-kit index 0b44d35..5093e1c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 0b44d353d2a617a0c0e0b9f8c71948e2e97e37e4 +Subproject commit 5093e1cc5fe40c766f1354fc87762080051241b8