From 9a648f280d029cf91feec0f0aadd0150abe21ec6 Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Sat, 10 Oct 2026 19:34:43 +0200 Subject: [PATCH] feat: Show settings beside their pages on large screens On a tablet, an unfolded foldable or any window wide enough for two panes, Settings now stays on the left and the page chosen from it opens on the right, with a placeholder until one is chosen. This uses Material's list-detail scene strategy for Navigation 3. Choosing another row replaces the open page instead of stacking it, Back closes the page and then Settings, and Settings' own arrow leaves Settings altogether. A page shown beside the list drops its back arrow; a page nested inside one, such as a server being edited, keeps it. Narrow windows are unchanged. The server screen is only a pane when opened from Settings; from the library it remains a screen of its own, and the licences stay full screen. On e-paper the panes switch without motion. --- DEVELOPER.md | 7 ++ app/build.gradle.kts | 1 + .../kotlin/com/chmouel/liseur/MainActivity.kt | 74 ++++++++++++++++--- .../liseur/providers/ServicesScreen.kt | 32 ++++---- .../translate/TranslationSettingsScreen.kt | 18 +++-- .../liseur/tts/ReadAloudSettingsScreen.kt | 11 +-- .../chmouel/liseur/ui/navigation/Routes.kt | 37 ++++++++++ .../chmouel/liseur/ui/settings/AboutScreen.kt | 11 +-- .../liseur/ui/settings/HiddenBooksScreen.kt | 12 +-- .../liseur/ui/settings/PaneBackButton.kt | 50 +++++++++++++ .../ui/settings/ReadingAppearanceScreen.kt | 12 +-- .../ui/settings/ReadingNavigationScreen.kt | 12 +-- .../liseur/ui/settings/ServerAccountScreen.kt | 10 +-- .../ui/settings/SettingsBackupScreen.kt | 11 +-- .../ui/settings/SettingsPagePlaceholder.kt | 54 ++++++++++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../liseur/ui/navigation/RoutesTest.kt | 31 ++++++++ gradle/libs.versions.toml | 2 + 24 files changed, 288 insertions(+), 104 deletions(-) create mode 100644 app/src/main/kotlin/com/chmouel/liseur/ui/settings/PaneBackButton.kt create mode 100644 app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsPagePlaceholder.kt diff --git a/DEVELOPER.md b/DEVELOPER.md index 0115f6b2..80eb5f0c 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -1156,6 +1156,13 @@ Key decisions: previewed. ViewModels stay activity-scoped and shared between screens; there is deliberately no per-entry ViewModel decorator. The reader is a separate activity and does not use it. +- On a wide window Settings is a list pane and the page chosen from it a + detail pane, through Material's `ListDetailSceneStrategy` (Navigation 3 + scenes, not `ListDetailPaneScaffold`). Back pops one route at a time + (`PopLatest`); choosing another row replaces the open page + (`openDetail`). Pages use `PaneBackButton`, which hides the arrow while + the list is beside them; a page nested inside one wraps itself in + `NestedPage` to keep its arrow. On e-paper the panes get no motion. ## Implementation invariants diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 40d2f2c3..4b3ed0d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -211,6 +211,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.compose.material3.adaptive.navigation3) implementation(libs.androidx.fragment.compose) implementation(platform(libs.compose.bom)) diff --git a/app/src/main/kotlin/com/chmouel/liseur/MainActivity.kt b/app/src/main/kotlin/com/chmouel/liseur/MainActivity.kt index ba7ba8a2..5c701151 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/MainActivity.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/MainActivity.kt @@ -16,6 +16,10 @@ import androidx.activity.compose.LocalActivity import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -66,6 +70,7 @@ import com.chmouel.liseur.ui.settings.ServerAccountViewModel import com.chmouel.liseur.ui.settings.AboutScreen import com.chmouel.liseur.ui.settings.AppLocales import com.chmouel.liseur.ui.settings.LicencesScreen +import com.chmouel.liseur.ui.settings.SettingsPagePlaceholder import com.chmouel.liseur.ui.settings.SettingsScreen import com.chmouel.liseur.ui.settings.SettingsBackupScreen import com.chmouel.liseur.ui.settings.ReadingAppearanceScreen @@ -95,6 +100,7 @@ import kotlinx.coroutines.Job import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.snap import androidx.compose.animation.togetherWith import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateListOf @@ -111,8 +117,11 @@ import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner import com.chmouel.liseur.ui.navigation.LaunchStack import com.chmouel.liseur.ui.navigation.Route import com.chmouel.liseur.ui.navigation.RouteBackStackSaver +import com.chmouel.liseur.ui.navigation.closeSettings import com.chmouel.liseur.ui.navigation.contentKey +import com.chmouel.liseur.ui.navigation.isSettingsPage import com.chmouel.liseur.ui.navigation.launchStack +import com.chmouel.liseur.ui.navigation.openDetail import com.chmouel.liseur.ui.navigation.pop import com.chmouel.liseur.ui.navigation.push @@ -244,6 +253,7 @@ class MainActivity : ComponentActivity() { private const val SOURCE_URL = "https://github.com/chmouel/liseur" private const val SPONSOR_URL = "https://github.com/sponsors/chmouel" +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable private fun LiseurApp( settings: AppSettings, @@ -295,11 +305,41 @@ private fun LiseurApp( if (backStack.lastOrNull() == Route.SettingsBackup) settingsZipBackup.close() backStack.pop() } + // A page chosen from Settings takes the place of the one open beside it. + val openPage: (Route) -> Unit = { page -> + if (backStack.lastOrNull() == Route.SettingsBackup && page != Route.SettingsBackup) { + settingsZipBackup.close() + } + backStack.openDetail(page) + } + // Settings' own arrow leaves Settings, page beside it and all. + val closeSettings: () -> Unit = { + if (Route.SettingsBackup in backStack) settingsZipBackup.close() + backStack.closeSettings() + } val eInk = LocalEInk.current + // Settings beside its pages on a wide window. Back pops one route at a + // time: the default would skip Settings, whose list-and-placeholder + // layout looks the same to the scaffold as list-and-page. + val listDetail = rememberListDetailSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopLatest, + ) + // NavDisplay's transitions do not reach the panes, which animate on + // their own; on e-paper they are told not to. + val paneMotion = if (eInk) { + ListDetailSceneStrategy.paneAnimation( + enterTransition = EnterTransition.None, + exitTransition = ExitTransition.None, + boundsAnimationSpec = snap(), + ) + } else { + emptyMap() + } NavDisplay( backStack = backStack, onBack = back, + sceneStrategies = listOf(listDetail), entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberTopEntryBackDecorator(backStack), @@ -322,7 +362,19 @@ private fun LiseurApp( defaultPredictivePopTransitionSpec() }, entryProvider = { route -> - NavEntry(route, contentKey = route.contentKey) { + val below = backStack.getOrNull(backStack.indexOf(route) - 1) + val pane = when { + route == Route.Settings -> ListDetailSceneStrategy.listPane( + detailPlaceholder = { SettingsPagePlaceholder() }, + ) + paneMotion + // The server screen is also opened from the library, and + // there it is a screen of its own. + route.isSettingsPage && + (route != Route.ServerAccount || below == Route.Settings) -> + ListDetailSceneStrategy.detailPane() + paneMotion + else -> emptyMap() + } + NavEntry(route, contentKey = route.contentKey, metadata = pane) { when (route) { Route.Library -> androidx.compose.runtime.key(libraryLaunchId) { LibraryRoute( @@ -411,20 +463,20 @@ private fun LiseurApp( dynamicColorAvailable = dynamicColorAvailable, onThemeMode = { scope.launch { repository.setThemeMode(it) } }, onDynamicColor = { scope.launch { repository.setDynamicColor(it) } }, - onOpenAccount = { backStack.push(Route.ServerAccount) }, - onOpenReadingAppearance = { backStack.push(Route.ReadingAppearance) }, - onOpenReadingNavigation = { backStack.push(Route.ReadingNavigation) }, - onOpenSettingsBackup = { backStack.push(Route.SettingsBackup) }, - onOpenHiddenBooks = { backStack.push(Route.HiddenBooks) }, + onOpenAccount = { openPage(Route.ServerAccount) }, + onOpenReadingAppearance = { openPage(Route.ReadingAppearance) }, + onOpenReadingNavigation = { openPage(Route.ReadingNavigation) }, + onOpenSettingsBackup = { openPage(Route.SettingsBackup) }, + onOpenHiddenBooks = { openPage(Route.HiddenBooks) }, libraryFolders = library.libraryFolders, onRemoveFolder = { library.removeFolder(it) }, server = context.container.remoteAccount.server, - onOpenAbout = { backStack.push(Route.About) }, - onBack = back, + onOpenAbout = { openPage(Route.About) }, + onBack = closeSettings, flavorReadingRows = { - context.container.readAloud.SettingsEntry(onClick = { backStack.push(Route.ReadAloud) }) - context.container.translate.SettingsEntry(onClick = { backStack.push(Route.Translation) }) - ServicesEntry(onClick = { backStack.push(Route.Services) }) + context.container.readAloud.SettingsEntry(onClick = { openPage(Route.ReadAloud) }) + context.container.translate.SettingsEntry(onClick = { openPage(Route.Translation) }) + ServicesEntry(onClick = { openPage(Route.Services) }) }, ) } diff --git a/app/src/main/kotlin/com/chmouel/liseur/providers/ServicesScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/providers/ServicesScreen.kt index 87242b03..5ed3c424 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/providers/ServicesScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/providers/ServicesScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Dns @@ -92,6 +91,8 @@ import com.chmouel.liseur.tts.SpeechServerPresets import com.chmouel.liseur.tts.onLeaving import com.chmouel.liseur.ui.contentWidthCap import com.chmouel.liseur.ui.settings.ConnectionRow +import com.chmouel.liseur.ui.settings.NestedPage +import com.chmouel.liseur.ui.settings.PaneBackButton import com.chmouel.liseur.ui.settings.RowDivider import com.chmouel.liseur.ui.settings.SettingsGroup import com.chmouel.liseur.ui.windowWidth @@ -135,16 +136,19 @@ internal fun ServicesScreen(connections: ServerConnections, accounts: ServiceAcc editing?.let { opened -> val id = drafts[draft] ?: opened.takeIf { it != NEW_SERVER } BackHandler { editing = null } - key(draft) { - ServerScreen( - connections = connections, - draft = draft, - id = id, - server = servers.readable?.firstOrNull { it.id == id }, - usedByReadAloud = id != null && id == readAloud, - usedByTranslation = id != null && id == translation, - onBack = { editing = null }, - ) + // A page inside this one: its arrow is the only way back out. + NestedPage { + key(draft) { + ServerScreen( + connections = connections, + draft = draft, + id = id, + server = servers.readable?.firstOrNull { it.id == id }, + usedByReadAloud = id != null && id == readAloud, + usedByTranslation = id != null && id == translation, + onBack = { editing = null }, + ) + } } return } @@ -214,11 +218,7 @@ private fun SettingsPage( topBar = { LargeTopAppBar( title = { Text(title) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, + navigationIcon = { PaneBackButton(onBack) }, actions = { actions() }, scrollBehavior = scrollBehavior, ) diff --git a/app/src/main/kotlin/com/chmouel/liseur/translate/TranslationSettingsScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/translate/TranslationSettingsScreen.kt index a09c7118..674774e4 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/translate/TranslationSettingsScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/translate/TranslationSettingsScreen.kt @@ -16,13 +16,11 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Translate import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -69,6 +67,8 @@ import com.chmouel.liseur.ui.BusyIndicator import com.chmouel.liseur.ui.LiseurModalBottomSheet import com.chmouel.liseur.ui.contentWidthCap import com.chmouel.liseur.ui.settings.ConnectionRow +import com.chmouel.liseur.ui.settings.NestedPage +import com.chmouel.liseur.ui.settings.PaneBackButton import com.chmouel.liseur.ui.settings.SettingsGroup import com.chmouel.liseur.ui.windowWidth import java.text.NumberFormat @@ -110,7 +110,13 @@ internal fun TranslationSettingsScreen(feature: ServiceTranslate, onBack: () -> // Opened straight onto the Services page, back leaves altogether. val leave = if (services) onBack else ({ managing = false }) BackHandler(onBack = leave) - feature.ServicesPage(onBack = leave) + // Opened from this screen, the page needs its own way back to it; + // opened straight onto it, leaving it leaves the screen. + if (services) { + feature.ServicesPage(onBack = leave) + } else { + NestedPage { feature.ServicesPage(onBack = leave) } + } return } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -126,11 +132,7 @@ internal fun TranslationSettingsScreen(feature: ServiceTranslate, onBack: () -> topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.translation_settings_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudSettingsScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudSettingsScreen.kt index 126ffbeb..298e8a84 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudSettingsScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudSettingsScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.VolumeUp import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop @@ -100,6 +99,8 @@ import com.chmouel.liseur.providers.ServiceSheet import com.chmouel.liseur.ui.LiseurModalBottomSheet import com.chmouel.liseur.ui.contentWidthCap import com.chmouel.liseur.ui.settings.ConnectionRow +import com.chmouel.liseur.ui.settings.NestedPage +import com.chmouel.liseur.ui.settings.PaneBackButton import com.chmouel.liseur.ui.settings.SettingsGroup import com.chmouel.liseur.ui.windowWidth import kotlinx.coroutines.CoroutineScope @@ -142,7 +143,7 @@ internal fun ReadAloudSettingsScreen(feature: SpeechReadAloud, onBack: () -> Uni var managing by rememberSaveable { mutableStateOf(false) } if (managing) { BackHandler { managing = false } - feature.ServicesPage(onBack = { managing = false }) + NestedPage { feature.ServicesPage(onBack = { managing = false }) } return } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -157,11 +158,7 @@ internal fun ReadAloudSettingsScreen(feature: SpeechReadAloud, onBack: () -> Uni topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.read_aloud_settings_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/navigation/Routes.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/navigation/Routes.kt index c61d40b4..4d57ec9b 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/navigation/Routes.kt @@ -113,6 +113,43 @@ fun MutableList.pop() { if (size > 1) removeAt(lastIndex) } +/** Pages opened from Settings, shown beside it on a wide window. */ +val Route.isSettingsPage: Boolean + get() = when (this) { + Route.ReadingAppearance, Route.ReadingNavigation, Route.ReadAloud, + Route.Translation, Route.Services, Route.SettingsBackup, + Route.HiddenBooks, Route.About, Route.ServerAccount, + -> true + else -> false + } + +/** + * Opens a Settings page in place of whatever page is open beside Settings. + * + * Choosing another row on a wide window swaps the page rather than piling + * it on top of the last one, so Back still returns to Settings in one step. + * Choosing the row already open does nothing, keeping its place. Without + * Settings on the stack, it is an ordinary [push]. + */ +fun MutableList.openDetail(route: Route) { + if (lastOrNull() == route) return + val settings = lastIndexOf(Route.Settings) + if (settings >= 0) { + while (size > settings + 1) removeAt(lastIndex) + } + add(route) +} + +/** Closes Settings and any page open beside it. */ +fun MutableList.closeSettings() { + val settings = lastIndexOf(Route.Settings) + if (settings <= 0) { + pop() + return + } + while (size > settings) removeAt(lastIndex) +} + /** The stack a widget or shortcut launch starts from. */ fun launchStack(target: LaunchStack): List = when (target) { LaunchStack.LIBRARY -> listOf(Route.Library) diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/AboutScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/AboutScreen.kt index 96a2dfa7..ffcf3d98 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/AboutScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/AboutScreen.kt @@ -20,11 +20,9 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -109,14 +107,7 @@ fun AboutScreen( topBar = { TopAppBar( title = { Text(stringResource(R.string.about_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, ) }, ) { padding -> diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/HiddenBooksScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/HiddenBooksScreen.kt index 6ed19350..2149d0cc 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/HiddenBooksScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/HiddenBooksScreen.kt @@ -9,10 +9,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -55,14 +52,7 @@ fun HiddenBooksScreen( topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.hidden_books)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/PaneBackButton.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/PaneBackButton.kt new file mode 100644 index 00000000..3f3065aa --- /dev/null +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/PaneBackButton.kt @@ -0,0 +1,50 @@ +package com.chmouel.liseur.ui.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.navigation3.LocalListDetailSceneScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.res.stringResource +import com.chmouel.liseur.R + +/** + * The back arrow of a page opened from Settings. + * + * On a wide window the page sits beside the Settings list, and an arrow + * there would point at a list that is already on screen, so it is left + * out; Back still closes the page. A page inside the page — a server being + * edited, the services a feature manages — clears the scene scope around + * itself and keeps its arrow, since only that arrow leads back out of it. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun PaneBackButton(onBack: () -> Unit) { + val listShown = LocalListDetailSceneScope.current + ?.scaffoldTransitionScope + ?.scaffoldStateTransition + ?.targetState + ?.get(ListDetailPaneScaffoldRole.List) == PaneAdaptedValue.Expanded + if (listShown) return + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } +} + +/** Gives a page nested inside a Settings page its own back arrow. */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun NestedPage(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalListDetailSceneScope provides null, + content = content, + ) +} diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingAppearanceScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingAppearanceScreen.kt index 41743de3..e36986e0 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingAppearanceScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingAppearanceScreen.kt @@ -12,10 +12,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -113,14 +110,7 @@ fun ReadingAppearanceScreen( topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.settings_reading_appearance)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingNavigationScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingNavigationScreen.kt index 2b925c2d..ab19c5d9 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingNavigationScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ReadingNavigationScreen.kt @@ -13,13 +13,10 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType @@ -96,14 +93,7 @@ fun ReadingNavigationScreen( topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.settings_reading_navigation)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ServerAccountScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ServerAccountScreen.kt index 601cd3d4..3b23d11a 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ServerAccountScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/ServerAccountScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CloudOff @@ -175,14 +174,7 @@ fun ServerAccountScreen( topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.server_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsBackupScreen.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsBackupScreen.kt index 6b0c938a..acf06507 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsBackupScreen.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsBackupScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.FileOpen import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.FolderOpen @@ -18,7 +17,6 @@ import com.chmouel.liseur.data.library.BookExportResult import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -109,14 +107,7 @@ fun SettingsBackupScreen( topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.settings_backup_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, + navigationIcon = { PaneBackButton(onBack) }, scrollBehavior = scrollBehavior, ) }, diff --git a/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsPagePlaceholder.kt b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsPagePlaceholder.kt new file mode 100644 index 00000000..3d95d10b --- /dev/null +++ b/app/src/main/kotlin/com/chmouel/liseur/ui/settings/SettingsPagePlaceholder.kt @@ -0,0 +1,54 @@ +package com.chmouel.liseur.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.chmouel.liseur.R + +/** + * What fills the page side of Settings on a wide window before a page is + * chosen: a quiet line saying what the space is for. + */ +@Composable +fun SettingsPagePlaceholder() { + Box( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.settings_page_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 35ebb667..c9a06a3b 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -746,6 +746,7 @@ 留空 阅读 阅读与导航 + 选择一项设置,在这里打开。 分页模式。翻页区域:%1$s 滚动阅读。 屏幕 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index afd1e084..7842fcbe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -730,6 +730,7 @@ Lesen Lesen & Navigation + Wähle eine Einstellung, um sie hier zu öffnen. Seitenweise. Umblätterseiten: %1$s Durch Scrollen lesen. Bildschirm diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2c66c7b5..5860526f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -719,6 +719,7 @@ Nada aquí Lectura Lectura y navegación + Elige un ajuste para abrirlo aquí. Por páginas. Lados de paso: %1$s Leer desplazando. Pantalla diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 135c6b5e..7c28dfc8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -719,6 +719,7 @@ Rien ici Lecture Lecture et navigation + Choisissez un réglage pour l\'ouvrir ici. Paginé. Côtés pour tourner la page : %1$s Lecture par défilement. Écran diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3c97b96f..af484e65 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -719,6 +719,7 @@ Niente qui Lettura Lettura e navigazione + Scegli un\'impostazione per aprirla qui. A pagine. Lati di svolta: %1$s Lettura a scorrimento. Schermo diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 53a7c250..9dc4485e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -769,6 +769,7 @@ Ничего Чтение Чтение и навигация + Выберите настройку, чтобы открыть её здесь. Постранично. Стороны перелистывания: %1$s Чтение прокруткой. Экран diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c548cfe..cc718341 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -754,6 +754,7 @@ Reading Reading & navigation + Choose a setting to open it here. Paged. Page-turn sides: %1$s Read by scrolling. Screen diff --git a/app/src/test/kotlin/com/chmouel/liseur/ui/navigation/RoutesTest.kt b/app/src/test/kotlin/com/chmouel/liseur/ui/navigation/RoutesTest.kt index 64fd3fb0..41756f5d 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/ui/navigation/RoutesTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/ui/navigation/RoutesTest.kt @@ -77,4 +77,35 @@ class RoutesTest { assertNotEquals(a.contentKey, b.contentKey) assertEquals("Settings", Route.Settings.contentKey) } + + @Test + fun anotherSettingsPageReplacesTheOpenOne() { + val stack = mutableListOf(Route.Library, Route.Settings, Route.About, Route.Licences) + stack.openDetail(Route.HiddenBooks) + assertEquals(listOf(Route.Library, Route.Settings, Route.HiddenBooks), stack) + } + + @Test + fun theOpenSettingsPageIsLeftAlone() { + val stack = mutableListOf(Route.Library, Route.Settings, Route.About, Route.Licences) + stack.openDetail(Route.Licences) + assertEquals(listOf(Route.Library, Route.Settings, Route.About, Route.Licences), stack) + } + + @Test + fun withoutSettingsAPageIsSimplyPushed() { + val stack = mutableListOf(Route.Library) + stack.openDetail(Route.ServerAccount) + assertEquals(listOf(Route.Library, Route.ServerAccount), stack) + } + + @Test + fun closingSettingsClosesThePageBesideIt() { + val stack = mutableListOf(Route.Library, Route.Settings, Route.About, Route.Licences) + stack.closeSettings() + assertEquals(listOf(Route.Library), stack) + val alone = mutableListOf(Route.Library, Route.Settings) + alone.closeSettings() + assertEquals(listOf(Route.Library), alone) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89466bda..0c2446b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ jsoup = "1.23.2" coroutines = "1.11.0" reorderable = "3.1.0" navigation3 = "1.2.0" +adaptiveNavigation3 = "1.3.0" robolectric = "4.17" junit = "4.13.2" @@ -47,6 +48,7 @@ compose-material-icons-extended = { module = "androidx.compose.material:material reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +compose-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version.ref = "adaptiveNavigation3" } readium-shared = { module = "org.readium.kotlin-toolkit:readium-shared", version.ref = "readium" } readium-streamer = { module = "org.readium.kotlin-toolkit:readium-streamer", version.ref = "readium" } readium-navigator = { module = "org.readium.kotlin-toolkit:readium-navigator", version.ref = "readium" }