Improve reader and library UI controls

This commit is contained in:
Sergey Chernov 2026-05-17 23:41:23 +03:00
parent 1239f15836
commit 06b4614cbe
7 changed files with 131 additions and 58 deletions

View File

@ -21,7 +21,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -58,9 +57,7 @@ internal fun BookInfoScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
) )
}, },
) { ) {

View File

@ -24,7 +24,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -105,7 +104,7 @@ internal fun ImageViewer(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Surface(color = MaterialTheme.colorScheme.surface) { ThemedTopBarSurface {
Row( Row(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -36,12 +37,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -53,6 +51,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
@ -205,9 +204,7 @@ internal fun LibraryScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
actions = { actions = {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) { IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library") Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
@ -412,14 +409,13 @@ private fun LibrarySearchField(
onClear: () -> Unit, onClear: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val shape = RoundedCornerShape(18.dp) val shape = RoundedCornerShape(16.dp)
OutlinedTextField( Row(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
modifier = modifier modifier = modifier
.height(56.dp) .height(42.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) { if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
onClear() onClear()
@ -428,38 +424,43 @@ private fun LibrarySearchField(
false false
} }
}, },
placeholder = { verticalAlignment = Alignment.CenterVertically,
Text( ) {
"Search library", Icon(
style = MaterialTheme.typography.bodyLarge, Icons.Filled.Search,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f), contentDescription = "Search library",
maxLines = 1, modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline,
)
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp, end = 6.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
) )
}, if (value.isBlank()) {
leadingIcon = { Text(
Icon( "Search library",
Icons.Filled.Search, style = MaterialTheme.typography.bodyMedium,
contentDescription = "Search library", color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
modifier = Modifier.size(20.dp), maxLines = 1,
tint = MaterialTheme.colorScheme.outline, )
)
},
trailingIcon = {
if (value.isNotBlank()) {
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp))
}
} }
}, }
shape = shape, if (value.isNotBlank()) {
colors = OutlinedTextFieldDefaults.colors( IconButton(onClick = onClear, modifier = Modifier.size(30.dp)) {
focusedBorderColor = MaterialTheme.colorScheme.primary, Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(17.dp))
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f), }
focusedContainerColor = MaterialTheme.colorScheme.surface, }
unfocusedContainerColor = MaterialTheme.colorScheme.surface, }
cursorColor = MaterialTheme.colorScheme.primary,
),
)
} }
@Composable @Composable

View File

@ -3,6 +3,10 @@ package net.sergeych.toread
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -38,6 +43,9 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@ -67,6 +75,7 @@ import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.text.HyphenationRegistry import net.sergeych.toread.text.HyphenationRegistry
import net.sergeych.toread.text.SoftHyphen import net.sergeych.toread.text.SoftHyphen
import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -79,6 +88,7 @@ internal fun ContinuousBookReader(
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
) { ) {
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val contentPadding = if (isAndroidPlatform()) { val contentPadding = if (isAndroidPlatform()) {
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp) PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
} else { } else {
@ -88,7 +98,19 @@ internal fun ContinuousBookReader(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface)
.pageTurnOnTouchTap(
onPageDown = {
scope.launch {
listState.animateScrollBy(listState.pageScrollDistance())
}
},
onPageUp = {
scope.launch {
listState.animateScrollBy(-listState.pageScrollDistance())
}
},
),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
@ -116,6 +138,34 @@ internal fun ContinuousBookReader(
} }
} }
private fun Modifier.pageTurnOnTouchTap(
onPageDown: () -> Unit,
onPageUp: () -> Unit,
): Modifier = pointerInput(onPageDown, onPageUp) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final)
if (down.type != PointerType.Touch) {
waitForUpOrCancellation(pass = PointerEventPass.Final)
return@awaitEachGesture
}
val up = waitForUpOrCancellation(pass = PointerEventPass.Final) ?: return@awaitEachGesture
if (up.isConsumed) return@awaitEachGesture
if (down.position.x < size.width / 2f) {
onPageDown()
} else {
onPageUp()
}
}
}
private fun LazyListState.pageScrollDistance(): Float {
val layoutInfo = layoutInfo
val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
return viewportHeight.toFloat().coerceAtLeast(0f)
}
private fun LazyListScope.sectionItems( private fun LazyListScope.sectionItems(
book: Fb2Book, book: Fb2Book,
section: Fb2Section, section: Fb2Section,
@ -411,8 +461,9 @@ private fun ReaderText(
@Composable @Composable
private fun readerParagraphTextStyle(language: String?): TextStyle = private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp, fontWeight = FontWeight(350),
lineHeight = 27.sp, fontSize = 21.sp,
lineHeight = 28.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified, hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified, lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },

View File

@ -19,7 +19,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -142,7 +141,7 @@ private fun CompactReaderTopBar(
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
Surface(color = MaterialTheme.colorScheme.surface) { ThemedTopBarSurface {
Row( Row(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -62,9 +61,7 @@ internal fun ScanScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
) )
}, },
) { ) {

View File

@ -3,16 +3,21 @@ package net.sergeych.toread
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -75,6 +80,30 @@ internal fun quietCardColors() = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
) )
@Composable
internal fun ThemedTopBarSurface(content: @Composable ColumnScope.() -> Unit) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Column {
content()
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun themedTopAppBarColors(): TopAppBarColors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
@Composable @Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background) internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)