Compare commits

..
Author SHA1 Message Date
makiolaj faa3e3817f Point floret-kit at main after #17 merged (#66) 2026-10-16 15:06:28 +02:00
makiolaj 71591f617a Bump floret-kit to latest main (#66) 2026-10-16 14:56:23 +02:00
makiolaj eea3a8b917 Address review on the Calendula alignment (#66)
Bumps floret-kit so a closing screen stops taking back, returns from
Storage to Import, and renames the per-list notifications row.
2026-10-16 14:42:47 +02:00
makiolaj a454c3685e 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.
2026-10-16 14:27:15 +02:00
18 changed files with 222 additions and 144 deletions
+7
View File
@@ -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 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). 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 ## [1.1.0] - 2026-10-10
### Added ### Added
+1 -1
View File
@@ -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<version>.apk`: Agendula Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
without CalDAV sync and **without the network permission**, so it cannot without CalDAV sync and **without the network permission**, so it cannot
connect to anything. It installs alongside the regular app rather than replacing 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 Obtainium, pick it with *Filter APKs by regular expression* set to
`^agendula-offline_v`. `^agendula-offline_v`.
@@ -70,6 +70,7 @@ internal fun AccountDetailScreen(
onSignInAgain: (AccountEntity) -> Unit, onSignInAgain: (AccountEntity) -> Unit,
onOpenStorage: () -> Unit, onOpenStorage: () -> Unit,
viewModel: AccountsViewModel, viewModel: AccountsViewModel,
visible: Boolean,
) { ) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -90,7 +91,7 @@ internal fun AccountDetailScreen(
// shown on the accounts list rather than here. // shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) } var confirming by remember { mutableStateOf(false) }
CollapsingScaffold(title = identity.title, onBack = onBack) { CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
AccountHero(identity) AccountHero(identity)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
onOpenStorage: () -> Unit, onOpenStorage: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: AccountsViewModel, viewModel: AccountsViewModel,
visible: Boolean,
) { ) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts), title = stringResource(R.string.settings_section_accounts),
onBack = onBack, onBack = onBack,
predictiveBack = true,
visible = visible,
) { ) {
val loaded = accounts ?: return@CollapsingScaffold val loaded = accounts ?: return@CollapsingScaffold
@@ -31,8 +31,8 @@ internal fun AccountSections(
// Which account the detail screen is showing; the section alone cannot say. // Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) } var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Storage // Accounts stays composed under Add account, for the same reason Backup
// stays composed under Export: the deeper screen slides over it. // stays composed under export and import: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts || val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) { SlideInSection(visible = accountsOpen) {
@@ -50,9 +50,11 @@ internal fun AccountSections(
onOpenStorage = { onSection(SettingsSection.Storage) }, onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) }, onBack = { onSection(null) },
viewModel = accountsViewModel, viewModel = accountsViewModel,
visible = accountsOpen,
) )
} }
SlideInSection(visible = section == SettingsSection.Account) { val accountOpen = section == SettingsSection.Account
SlideInSection(visible = accountOpen) {
openAccount?.let { id -> openAccount?.let { id ->
AccountDetailScreen( AccountDetailScreen(
accountId = id, accountId = id,
@@ -61,6 +63,7 @@ internal fun AccountSections(
onSignInAgain = signInAgain, onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) }, onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel, viewModel = accountsViewModel,
visible = accountOpen,
) )
} }
} }
+1
View File
@@ -50,6 +50,7 @@
<application <application
android:name=".AgendulaApp" android:name=".AgendulaApp"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents"
@Composable @Composable
fun ExportScreen( fun ExportScreen(
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel(), viewModel: ExportViewModel = hiltViewModel(),
) { ) {
@@ -89,6 +90,8 @@ fun ExportScreen(
title = stringResource(R.string.settings_export), title = stringResource(R.string.settings_export),
onBack = onBack, onBack = onBack,
modifier = modifier, modifier = modifier,
predictiveBack = true,
visible = visible,
) { ) {
Text( Text(
text = stringResource(R.string.export_hint), text = stringResource(R.string.export_hint),
@@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati
fun ImportScreen( fun ImportScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
predictiveBack: Boolean = false,
visible: Boolean? = null,
initialUri: Uri? = null, initialUri: Uri? = null,
onOpenStorage: (() -> Unit)? = null, onOpenStorage: (() -> Unit)? = null,
viewModel: ImportViewModel = hiltViewModel(), viewModel: ImportViewModel = hiltViewModel(),
@@ -105,6 +107,8 @@ fun ImportScreen(
title = stringResource(R.string.import_title), title = stringResource(R.string.import_title),
onBack = onBack, onBack = onBack,
modifier = modifier, modifier = modifier,
predictiveBack = predictiveBack,
visible = visible,
) { ) {
Text( Text(
text = stringResource(R.string.import_hint), text = stringResource(R.string.import_hint),
@@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* source, which is the specific thing §3.2(a) requires us to point at. * source, which is the specific thing §3.2(a) requires us to point at.
*/ */
@Composable @Composable
internal fun LicencesScreen(onBack: () -> Unit) { internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.licences_title), title = stringResource(R.string.licences_title),
onBack = onBack, onBack = onBack,
predictiveBack = true,
visible = visible,
) { ) {
Text( Text(
stringResource(R.string.licences_intro), stringResource(R.string.licences_intro),
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import kotlin.time.Instant 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.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
import de.jeanlucmakiola.floret.identity.predictiveBackExit
/** /**
* The single in-app back stack: lists → task list → detail / edit. * 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 * `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library. * unaware of the nav library.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun AgendulaNavHost( fun AgendulaNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -70,19 +74,19 @@ fun AgendulaNavHost(
onNavRequestConsumed() onNavRequestConsumed()
} }
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
val overlayExit = predictiveBackExit()
NavHost( NavHost(
navController = nav, navController = nav,
startDestination = Dest.LISTS, startDestination = Dest.LISTS,
modifier = modifier, modifier = modifier,
// Only the screen on top moves; the background is always held static. // Calendula's overlay motion. NavHost's own back handler seeks the exit
// Forward is a plain fade-in over the held screen — calm, not a slide. // over the previous screen, so destinations don't add a preview of their own.
// Back is the predictive-back peek: navigation-compose composes the enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() },
// screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None }, popEnterTransition = { EnterTransition.None },
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() }, popExitTransition = { overlayExit },
) { ) {
composable(Dest.LISTS) { composable(Dest.LISTS) {
ReadableWidth { ReadableWidth {
@@ -34,6 +34,7 @@ internal fun AppearanceScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
) { ) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) } var showTheme by remember { mutableStateOf(false) }
@@ -42,7 +43,12 @@ internal fun AppearanceScreen(
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
val locale = currentLocale() 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( GroupedRow(
title = stringResource(R.string.settings_theme), title = stringResource(R.string.settings_theme),
summary = stringResource(themeLabel(state.settings.themeMode)), summary = stringResource(themeLabel(state.settings.themeMode)),
@@ -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,
)
}
}
@@ -63,10 +63,11 @@ import java.time.LocalTime
/** Whether due tasks notify at all, how far ahead, and which lists differ. */ /** Whether due tasks notify at all, how far ahead, and which lists differ. */
@Composable @Composable
internal fun RemindersScreen( internal fun NotificationsScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) } var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) }
@@ -119,7 +120,12 @@ internal fun RemindersScreen(
) )
val lists = state.lists.filter { it.id > 0L } 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) { if (state.settings.remindersEnabled && !notificationsAllowed) {
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_notifications_blocked), title = stringResource(R.string.settings_notifications_blocked),
@@ -1,11 +1,8 @@
package de.jeanlucmakiola.agendula.ui.settings package de.jeanlucmakiola.agendula.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.BugReport
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite 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.filled.Tune
import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Storage import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport 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. */ /** The settings sub-screens reached from the hub's category rows. */
internal enum class SettingsSection { internal enum class SettingsSection {
Appearance, Appearance,
TaskForm, TaskForm,
Reminders, Notifications,
Storage, Storage,
Backup,
Export, Export,
Import, Import,
Accounts, Accounts,
@@ -72,10 +74,10 @@ internal enum class SettingsSection {
Licences, Licences,
; ;
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */ /** The section that stays composed underneath: Backup under export and import, Accounts under its screens. */
val parent: SettingsSection? val parent: SettingsSection?
get() = when (this) { get() = when (this) {
Export, Import -> Storage Export, Import -> Backup
AddAccount, Account -> Accounts AddAccount, Account -> Accounts
else -> null else -> null
} }
@@ -88,8 +90,9 @@ internal enum class SettingsSection {
* A full-screen destination; [onBack] pops it. [initialSection] names a * A full-screen destination; [onBack] pops it. [initialSection] names a
* sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]). * 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 * Every sub-screen is its own file and handles its own back through the
* the back rules between them, and nothing else. * predictive-back preview. This one owns the hub, the section enum and the
* routing between them, and nothing else.
*/ */
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -106,10 +109,8 @@ fun SettingsScreen(
else SettingsSection.entries.firstOrNull { it.name == initialSection }, else SettingsSection.entries.firstOrNull { it.name == initialSection },
) )
} }
// Import's "open storage" comes back to Import rather than the hub.
// Inside a sub-screen, system back (button or gesture) returns to the hub var storageReturn by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = section?.parent }
Box( Box(
modifier = modifier modifier = modifier
@@ -118,34 +119,58 @@ fun SettingsScreen(
) { ) {
SettingsHub(onBack = onBack, onOpenSection = { section = it }) SettingsHub(onBack = onBack, onOpenSection = { section = it })
SlideInSection(visible = section == SettingsSection.Appearance) { val appearance = section == SettingsSection.Appearance
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) SlideInSection(visible = appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance)
} }
SlideInSection(visible = section == SettingsSection.TaskForm) { val taskForm = section == SettingsSection.TaskForm
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) SlideInSection(visible = taskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm)
} }
SlideInSection(visible = section == SettingsSection.Reminders) { val notifications = section == SettingsSection.Notifications
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) 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 storage = section == SettingsSection.Storage
val storageOpen = section == SettingsSection.Storage || SlideInSection(visible = storage) {
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
StorageScreen( StorageScreen(
viewModel = viewModel, viewModel = viewModel,
onBack = {
section = storageReturn
storageReturn = 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 }, onOpenExport = { section = SettingsSection.Export },
onOpenImport = { section = SettingsSection.Import }, onOpenImport = { section = SettingsSection.Import },
onBack = { section = null }, onBack = { section = null },
visible = backup,
) )
} }
SlideInSection(visible = section == SettingsSection.Export) { val exportOpen = section == SettingsSection.Export
ExportScreen(onBack = { section = SettingsSection.Storage }) SlideInSection(visible = exportOpen) {
ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen)
} }
SlideInSection(visible = section == SettingsSection.Import) { val importOpen = section == SettingsSection.Import
ImportScreen(onBack = { section = SettingsSection.Storage }) SlideInSection(visible = importOpen) {
ImportScreen(
onBack = { section = SettingsSection.Backup },
onOpenStorage = {
storageReturn = SettingsSection.Import
section = SettingsSection.Storage
},
predictiveBack = true,
visible = importOpen,
)
} }
SlideInSection(visible = section == SettingsSection.Licences) { val licences = section == SettingsSection.Licences
LicencesScreen(onBack = { section = null }) SlideInSection(visible = licences) {
LicencesScreen(onBack = { section = null }, visible = licences)
} }
AccountSections( AccountSections(
section = section, section = section,
@@ -155,12 +180,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 @Composable
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = slideInHorizontally { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut(), exit = predictiveBackExit(),
) { content() } ) { content() }
} }
@@ -208,7 +239,7 @@ private fun SettingsHub(
summary = stringResource(R.string.settings_reminders_subtitle), summary = stringResource(R.string.settings_reminders_subtitle),
position = Position.Bottom, position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) }, onClick = { onOpenSection(SettingsSection.Notifications) },
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -225,14 +256,41 @@ private fun SettingsHub(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_section_storage), title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle), summary = stringResource(R.string.settings_storage_subtitle),
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone, position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Storage) }, 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)) Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.settings_group_app)) 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) ReportProblemRow(position = Position.Bottom)
// Source, licence and privacy sit at the bottom as reference material // Source, licence and privacy sit at the bottom as reference material
@@ -316,64 +374,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<String?>(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 * 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 * captured (and not yet sent), it surfaces that report first via the same
@@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
@Composable @Composable
internal fun StorageScreen( internal fun StorageScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle() 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. // permission dialog too, and would wipe the refusal before it is read.
OnResume { viewModel.refreshStorage() } 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( GroupedRow(
title = stringResource(R.string.settings_task_store), title = stringResource(R.string.settings_task_store),
summary = storage?.let { storeLabel(it) }, 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 // 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. // on the seeded state would offer the wrong store as the current one.
onClick = storage?.let { onClick = storage?.let {
@@ -99,31 +104,19 @@ internal fun StorageScreen(
} }
}, },
) )
if (storage?.canCopyFromExternal == true) { if (canCopy) {
GroupedRow( GroupedRow(
title = stringResource( title = stringResource(
R.string.settings_copy_from_external, R.string.settings_copy_from_external,
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(), storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
), ),
summary = stringResource(R.string.settings_copy_from_external_hint), summary = stringResource(R.string.settings_copy_from_external_hint),
position = Position.Middle, position = Position.Bottom,
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) }, leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
// Inert while a copy is in flight; the receipt below says so. // Inert while a copy is in flight; the receipt below says so.
onClick = if (transfer.running) null else viewModel::startCopyFromExternal, 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) TransferStatus(transfer)
@@ -42,10 +42,16 @@ internal fun TaskFormScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
) { ) {
var showDefaultList by remember { mutableStateOf(false) } 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)) SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
val fields = TaskFormField.entries val fields = TaskFormField.entries
+15 -13
View File
@@ -122,8 +122,8 @@
<string name="reminder_unit_days">Days</string> <string name="reminder_unit_days">Days</string>
<string name="reminder_unit_weeks">Weeks</string> <string name="reminder_unit_weeks">Weeks</string>
<string name="settings_list_reminders_title">Per-list reminders</string> <string name="settings_list_reminders_title">Per-list reminders</string>
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string> <string name="settings_list_reminders_hint">Override the default per list — separately for timed and all-day tasks. A list can keep the default, drop it, or set its own.</string>
<string name="settings_list_notifications">Notifications</string> <string name="settings_list_notifications">Sound &amp; vibration</string>
<string name="settings_list_notifications_hint">Sound, vibration and importance for this list’s reminders</string> <string name="settings_list_notifications_hint">Sound, vibration and importance for this list’s reminders</string>
<string name="settings_list_reminder_inherits">Default (%1$s)</string> <string name="settings_list_reminder_inherits">Default (%1$s)</string>
@@ -359,7 +359,7 @@
<!-- First run: backup --> <!-- First run: backup -->
<string name="onboarding_backup_title">Keep a copy</string> <string name="onboarding_backup_title">Keep a copy</string>
<string name="onboarding_backup_body_later">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.</string> <string name="onboarding_backup_body_later">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 &amp; restore → Export.</string>
<string name="onboarding_backup_continue">Continue</string> <string name="onboarding_backup_continue">Continue</string>
<!-- First run: quick add bar --> <!-- First run: quick add bar -->
@@ -394,10 +394,10 @@
<!-- Group of reference links at the foot of the settings hub. --> <!-- Group of reference links at the foot of the settings hub. -->
<string name="settings_group_about">About</string> <string name="settings_group_about">About</string>
<string name="settings_section_appearance">Appearance</string> <string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme, colour and time format</string> <string name="settings_appearance_subtitle">Theme, colours, time format</string>
<string name="settings_section_task_form">Task form</string> <string name="settings_section_task_form">New task form</string>
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string> <string name="settings_task_form_subtitle">Default fields and behaviour</string>
<string name="settings_reminders_subtitle">Notifications for due tasks</string> <string name="settings_reminders_subtitle">Reminders and delivery</string>
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string> <string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string> <string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
<string name="settings_autofocus_title">Focus title on new task</string> <string name="settings_autofocus_title">Focus title on new task</string>
@@ -442,13 +442,13 @@
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string> <string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Reminders</string> <string name="settings_section_reminders">Notifications</string>
<string name="settings_reminders">Due reminders</string> <string name="settings_reminders">Task reminders</string>
<string name="settings_reminders_hint">Notify me when a task is due</string> <string name="settings_reminders_hint">Notify me when a task is due</string>
<string name="settings_notifications_blocked">Notifications are off</string> <string name="settings_notifications_blocked">Notifications are off</string>
<string name="settings_notifications_blocked_hint">Reminders can\u2019t reach you. Tap to allow notifications for Agendula.</string> <string name="settings_notifications_blocked_hint">Reminders can\u2019t reach you. Tap to allow notifications for Agendula.</string>
<string name="settings_default_reminder">When to remind</string> <string name="settings_default_reminder">Default reminder</string>
<string name="settings_allday_reminder_time">Time for all-day reminders</string> <string name="settings_allday_reminder_time">All-day reminder time</string>
<string name="settings_allday_reminder_time_summary">All-day tasks count back from %1$s</string> <string name="settings_allday_reminder_time_summary">All-day tasks count back from %1$s</string>
<string name="settings_default_reminder_allday">All-day tasks</string> <string name="settings_default_reminder_allday">All-day tasks</string>
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string> <string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
@@ -472,9 +472,11 @@
<!-- Storage and export --> <!-- Storage and export -->
<string name="settings_section_storage">Storage</string> <string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string> <string name="settings_storage_subtitle">Where your tasks are kept</string>
<string name="settings_section_backup">Backup &amp; restore</string>
<string name="settings_backup_subtitle">Export and import</string>
<string name="settings_task_store">Task store</string> <string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</string> <string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them from Backup &amp; restore.</string>
<string name="settings_store_own">On this device</string> <string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string> <string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string> <string name="settings_store_external">Another task app</string>