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.
This commit is contained in:
Chmouel Boudjnah 2026-10-10 19:34:43 +02:00
parent 5fdde2414e
commit 9a648f280d
24 changed files with 288 additions and 104 deletions

View file

@ -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

View file

@ -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))

View file

@ -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<Route>(
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) })
},
)
}

View file

@ -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,
)

View file

@ -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,
)
},

View file

@ -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,
)
},

View file

@ -113,6 +113,43 @@ fun MutableList<Route>.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<Route>.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<Route>.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<Route> = when (target) {
LaunchStack.LIBRARY -> listOf(Route.Library)

View file

@ -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 ->

View file

@ -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,
)
},

View file

@ -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,
)
}

View file

@ -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,
)
},

View file

@ -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,
)
},

View file

@ -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,
)
},

View file

@ -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,
)
},

View file

@ -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,
)
}
}
}

View file

@ -746,6 +746,7 @@
<string name="footer_field_empty">留空</string>
<string name="settings_reading">阅读</string>
<string name="settings_reading_navigation">阅读与导航</string>
<string name="settings_page_placeholder">选择一项设置,在这里打开。</string>
<string name="settings_reading_navigation_paged">分页模式。翻页区域:%1$s</string>
<string name="settings_reading_navigation_scrolling">滚动阅读。</string>
<string name="settings_screen">屏幕</string>

View file

@ -730,6 +730,7 @@
<string name="settings_reading">Lesen</string>
<string name="settings_reading_navigation">Lesen &amp; Navigation</string>
<string name="settings_page_placeholder">Wähle eine Einstellung, um sie hier zu öffnen.</string>
<string name="settings_reading_navigation_paged">Seitenweise. Umblätterseiten: %1$s</string>
<string name="settings_reading_navigation_scrolling">Durch Scrollen lesen.</string>
<string name="settings_screen">Bildschirm</string>

View file

@ -719,6 +719,7 @@
<string name="footer_field_empty">Nada aquí</string>
<string name="settings_reading">Lectura</string>
<string name="settings_reading_navigation">Lectura y navegación</string>
<string name="settings_page_placeholder">Elige un ajuste para abrirlo aquí.</string>
<string name="settings_reading_navigation_paged">Por páginas. Lados de paso: %1$s</string>
<string name="settings_reading_navigation_scrolling">Leer desplazando.</string>
<string name="settings_screen">Pantalla</string>

View file

@ -719,6 +719,7 @@
<string name="footer_field_empty">Rien ici</string>
<string name="settings_reading">Lecture</string>
<string name="settings_reading_navigation">Lecture et navigation</string>
<string name="settings_page_placeholder">Choisissez un réglage pour l\'ouvrir ici.</string>
<string name="settings_reading_navigation_paged">Paginé. Côtés pour tourner la page : %1$s</string>
<string name="settings_reading_navigation_scrolling">Lecture par défilement.</string>
<string name="settings_screen">Écran</string>

View file

@ -719,6 +719,7 @@
<string name="footer_field_empty">Niente qui</string>
<string name="settings_reading">Lettura</string>
<string name="settings_reading_navigation">Lettura e navigazione</string>
<string name="settings_page_placeholder">Scegli un\'impostazione per aprirla qui.</string>
<string name="settings_reading_navigation_paged">A pagine. Lati di svolta: %1$s</string>
<string name="settings_reading_navigation_scrolling">Lettura a scorrimento.</string>
<string name="settings_screen">Schermo</string>

View file

@ -769,6 +769,7 @@
<string name="footer_field_empty">Ничего</string>
<string name="settings_reading">Чтение</string>
<string name="settings_reading_navigation">Чтение и навигация</string>
<string name="settings_page_placeholder">Выберите настройку, чтобы открыть её здесь.</string>
<string name="settings_reading_navigation_paged">Постранично. Стороны перелистывания: %1$s</string>
<string name="settings_reading_navigation_scrolling">Чтение прокруткой.</string>
<string name="settings_screen">Экран</string>

View file

@ -754,6 +754,7 @@
<string name="settings_reading">Reading</string>
<string name="settings_reading_navigation">Reading &amp; navigation</string>
<string name="settings_page_placeholder">Choose a setting to open it here.</string>
<string name="settings_reading_navigation_paged">Paged. Page-turn sides: %1$s</string>
<string name="settings_reading_navigation_scrolling">Read by scrolling.</string>
<string name="settings_screen">Screen</string>

View file

@ -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>(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)
}
}

View file

@ -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" }